Skip to content

Writing new Drivers

William Wood edited this page Apr 26, 2023 · 10 revisions

Writing new VISA Drivers

If you want to use JISA but the instrument you wish to control does not yet have a driver class written for it then fear not, for you can write one yourself! The only snag is that if you want your driver to be included into the JISA library then you must write it in Java because I do not want the library to become a mish-mash of different languages (for my own sanity if nothing else).

That being said, it is perfectly possible to extend the library using Kotlin or Python, so long as you're not intending on sharing your work. In this tutorial, however, we will stick to Java.

Communication Base Classes

The first thing you need to figure out is how communication is performed to/from your instrument. Instruments so-far covered by JISA communicate in one of three ways:

  1. Via text sent over GPIB/serial/TCP-IP/USB etc. So-called "VISA" type communication.
  2. Using an industry standard called MODBUS-RTU.
  3. By use of a black-box native library.

For each of these, there is a base-class to extend that will provide you with the necessary functionality. For VISA-type instruments you will want to extend VISADevice, for MODBUS-RTU there's ModbusRTUDevice and for native libraries there's NativeDevice.

In this tutorial we will focus of VISADevice as it is likely that you're wanting to use this. For the other two, evertying fomr "Implementing an Interface" onwards will apply, just the specifics of what you write in the methods and how you connect will be different.

Extending VISADevice

In JISA, interfacing with VISA is done via the VISADevice class. Instantiating a VISADevice object, and giving it the relevant Address object will cause JISA to open a connection to the instrument (either using VISA, or by it stepping it and pretending to be VISA when VISA inevtiably fails). The object then represents that connection giving you access to read, write and query methods:

VISADevice instrument = new VISADevice(new GPIBAddress(2));
String     response   = instrument.query("*IDN?");
double     voltage    = instrument.queryDouble("MEASURE:VOLTAGE?");
int        state      = instrument.queryInt("OUTPUT:ENABLED?");
instrument.write("OUTPUT:ENABLED 1");

The idea then is to extend VISADevice, adding methods that use these basic I/O methods to perform pre-defined actions, for instance:

public double getVoltage() throws IOException {
    return queryDouble("MEASURE:VOLTAGE?");
}

First things first, we need create our class. By extending VISADevice you will be required to implement a constructor that passes an Address object onto the super/parent constructor:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException {
        super(address);
    }

}

Setting Read and Write Terminators

Normally, you will find that simply connecting to an instrument is not enough, you also need to configure various communication parameters. For instance, both your computer an the instrument you are trying to control require a means of known when the other has finished "talking". Over some types of connection there are in-built protocols for communicating this, such as the EOI line for GPIB. In other situations you will likely need to rely on so-called termination characters or "terminators". These are special characters that get added to the end of messages to indicate that the message has ended. The two most common are the "line-feed" (LF), and "carriage-return" (CR) characters. These are normally represented in code as \n and \r respectively. It is therefore important that you read the instruction manual for your instrument to understand what termination procedure it requires.

For instance, if your instrument requires all messages sent to and from it to be terminated with LF characters, you will need to configure your VISADevice object to use them in its constructor, like so:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException, DeviceException {

        super(address);

        // Adds \n to the end of all outgoing messages
        setWriteTerminator("\n"); 
        
        // Tells JISA to look for \n when reading incoming messages
        setReadTerminator("\n");


    }

}

This is important because if, say, the read termination character was not set correctly then all read() and query() methods will time-out waiting for a response from the instrument (ie they will assume the instrument is still transmitting its message if they don't see the read termination character).

Auto-Removing Unwanted Characters

Sometimes, when using line terminators, said terminators can mess with the logic of your driver, since said line terminators will still be present in the message after it is received. For instance, if your instrument used a full-stop . to terminate messages, then it could cause issues when trying to parse a response into a number. For this reason, VISADevice provides the addAutoRemove(...) method. You can use this method to specify as many different characters and phrases as you want to be automatically removed from any received message.

In our previous example, we said that our instrument used a line-feed character \n to terminate messages. It may also be the case that our instrument sometimes adds a carriage-return character as well (\r). Therefore, if we want to clean said characters from incoming messages so that they do not interfere, we can add the following line:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException, DeviceException {

        super(address);

        setWriteTerminator("\n"); 
        setReadTerminator("\n");

        addAutoRemove("\n", "\r"); // Auto remove any LF or CR characters


    }

}

This removal is done after the message is received (i.e. after detecting the terminator), but before any conversion to int or double etc is performed.

I/O Frequency Limits

Some instruments have limits on how frequently they can be written to or read from. If this is the case for your instrument, you can instruct VISADevice to limit the rate at which it does this automatically by use of setIOLimit(...):

setIOLimit(int minMS, boolean read, boolean write);

In this method, minMS is the minimum interval between read/writes in milliseconds, read should be set to true if you want this limit to apply to read operations, and write should be set to true if you want it to apply to write operations. For instance, if our example instrument requires a minimum interval of 25 ms between all reads and writes, then we can add a setIOLimit(...) call to our constructor like so:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException, DeviceException {

        super(address);

        setWriteTerminator("\n"); 
        setReadTerminator("\n");
        addAutoRemove("\n", "\r");

        setIOLimit(25, true, true);

    }

}

Timeout and Retry Count

To configure how long a VISADevice should wait for a properly terminated message when requested before giving up (i.e. the "timeout"), you can call setTimeout(...):

setTimeOut(int timeoutMS);

where you must specify the desired timeout in milliseconds. Furthermore, you can also specify the number of attempts at reading the VISADevice should try before finally giving up by use of setRetryCount(...) (a value of n means n total attempts):

setRetryCount(int attempts);

Therefore, if we wanted our instrument to be connected with a timeout of 500 ms and for it to only attempt each read once, we would add:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException, DeviceException {

        super(address);

        setWriteTerminator("\n"); 
        setReadTerminator("\n");
        addAutoRemove("\n", "\r");
        setIOLimit(25, true, true);

        setTimeout(500);
        setRetryCount(1);

    }

}

Connection-Type-Specific Configurations

Some types of connection require specific configurations. For instance, serial connections require the baud rate etc of the connection to be specified, while GPIB connections often require their "End-or-Identify" (EOI) line to be enabled or disabled. These sorts of options are accessed through a Connection object that the VISADevice holds on to. To access this object, we call getConnection():

Connection connection = getConnection();

This would allow us to check what kind of connection we have by checking which Connection class the connection object actually is, by use of the instanceof operator:

if (connection instanceof GPIBConnection) {
    /* it is a GPIB connection */
}

if (connection instanceof SerialConnection) {
    /* it is a serial connection */
}

In theory, we could then cast our connection object to its specific class and call its connection-specific configuration methods, for instance to enable EOI on a GPIB connection:

if (connection instanceof GPIBConnection) {
    ((GPIBConnection) connection).setEOIEnabled(true);
}

However, this is a bit clumsy. Therefore, VISADevice provides the config(...) method:

config(ConnectionType.class, con -> {
    /* code to run if the connection type matches that specified */
});

which allows you to interact with a (pre-cast) connection object con in code that is only run if the connection object of the connection matches the specified class.

However, it also provides specific config methods to remove the need to specify classes, like so:

configGPIB(gpib -> {
    /* code to run if GPIB */
});

configSerial(serial -> {
    /* code to run if series */
});

configUSB(usb -> {
    /* code to run if USB */
});

configTCPIP(tcpip -> {
    /* code to run if TCP-IP */
});

configLXI(lxi -> {
    /* code to run if LXI/VX-11 */
});

These methods each take a lambda expression, with an argument representing the connection object in the instance of the connection type matching. For instance, in configGPIB(...) the argument of the lambda is of type GPIBConnection, thus it will contain the connection-type-specific methods for GPIB connections. As such, this lambda is only run if the connection type matches, thus you can also add any other configuration method calls within this lambda so that they will only be called for that specific connection type.

For instance, if in our example so far, the line terminators and io-frequency limit are only needed when communicating over serial (parameters: baud 9600, 8 data bits), with EOI being used instead when over GPIB to terminate messages, then we could write:

public class MyInstrument extends VISADevice {

    public MyInstrument(Address address) throws IOException, DeviceException {

        super(address);

        configForGPIB(gpib -> {
            gpib.setEOIEnabled(true);
        });

        configForSerial(serial -> {

            serial.setSerialParameters(9600, 8);

            setWriteTerminator("\n"); 
            setReadTerminator("\n");
            setIOLimit(25, true, true);

        });

        addAutoRemove("\n", "\r");

        setTimeout(500);
        setRetryCount(1);

    }

}

Here's a list of all the connection-type-specific methods:

configGPIB(gpib -> {

    // Instructs whether to use the EOI line
    gpib.setEOIEnabled(boolean enabled); 

    // Checks whether the EOI line is enabled
    boolean eoi = gpib.isEOIEnabled();

});

configSerial(serial -> {

    // Sets the parameters of the serial connection
    serial.setSerialParameters(
        int            baudRate, 
        int            dataBits, 
        Parity         party, 
        double         stopBits, 
        FlowControl... flowControls // Specify as many as needed (vararg)
    );

    // Same as above but assumes no parity, stopBits = 1.0, and no flow control options
    serial.setSerialParameters(int baudRate, int dataBits);

});

configTCPIP(tcpip -> {

    // Sets whether the TCP-IP connection should periodically send a "keep-alive"
    // message to ensure the connection doesn't automatically close
    tcpip.setKeepAliveEnabled(boolean enabled);

    // Checks whether the keep-alive feature is enabled
    boolean ka = tcpip.isKeepAliveEnabled();

});

These seem to be the most important connection-type-specific options, although there are doubtless others that may get implemented over time.

Write, Read and Query

VISADevice provides us with a set of I/O methods. These are:

VISADevice device = new VISADevice(new SerialAddress("COM5"));

device.write(message, ...);

String message  = device.read();
int    message  = device.readInt();
double message  = device.readDouble();

String response = device.query(message, ...);
int    response = device.queryInt(message, ...);
double response = device.queryDouble(message, ...);

The query...() methods perform a write(...) immediately followed up by a read(), returning the read value. It is best to use these if you are expecting a response from a command as it will prevent other threads writing/reading to/from your device between the write and read of the query (ie it thread-locks).

Each ... indicates that you can use formatting parameters, for instance:

device.write("SET VOLTAGE %e", voltageValue);

For both read() and query() we have extra methods that convert the returned string into either an integer or double.

Therefore, within a class that extends VISADevice, one can call the various write(...), read(), and query(...) methods to implement required functionality.

Checking Instrument Make/Model

Next you will likely want to add a check to the constructor to make sure the instrument you've connected to is actually the make/model that your driver is designed for. To do this, you should query the instrument for some sort of identification string (normally done by sending "*IDN?").

For instance, if we know the correct instrument would respond to "*IDN?" with "Manucorp Instrumentblurg Model 12345, Rev 1348473" then we could check to make sure the response contains the words "Instrumentblurg Model 12345" and throw a DeviceException if it doesn't like so:

public class MyInstrument extends VISADevice {

  public MyInstrument(Address address) throws IOException, DeviceException {

    super(address);

    /* configuration methods from before go here */

    String idn = query("*IDN?");

    if (!idn.contains("Instrumentblurg Model 12345")) {
      throw new DeviceException("This is not a model 12345!");
    }

  }

}

This way, your driver cannot be used to try and control an instrument that it is not designed to control (and lead to potentially disastrous real-world consequences).

Implementing an Interface

As mentioned before, the idea is that we want to write some methods that wrap around these write, query etc methods to perform standard actions. However, what these methods should be called, what arguments they should and how they should be implemented is very important in the context JISA. This is because of the core aims of the project is to standardise how instruments are implemented.

To make sure your driver is compliant with these standards (thus allowing it to be used with the rest of the library) you should implement one of the standard instrument interfaces. For example, if your instrument is an SMU it should implement the SMU interface.

Below is a list of all the interfaces currently available:

Instrument Type Interface
Voltmeter VMeter
Ammeter IMeter
Multimeter IVMeter
Voltage Source VSource
Current Source ISource
Multi Source IVSource
DC Power Supply DCPower
SMU (single channel) SMU
SMU (multi channel) MCSMU
Thermometer (single sensor) TMeter
Thermometer (multi sensor) MSTMeter
General PID Controller PID
Temperature PID Controller TC
Lock-In Amplifier LockIn
Dual-Phase Lock-In DPLockIn
Voltage Pre-Amplifier VPreAmp
Container of multiple other instruments MultiInstrument

When you have chosen the most appropriate interface(s) you should then make your class implement them by adding implements .... For example, if our instrument was a DC power supply:

public class MyInstrument extends VISADevice implements DCPower {

    public MyInstrument(Address address) throws IOException, DeviceException {
        ...
    }

}

This will then require you to implement the standard DCPower methods. If you are using IDEA you can press ALT + ENTER on the class name and select implement methods to automatically generate a blank set of methods:

public class MyInstrument extends VISADevice implements DCPower {

    public MyInstrument(Address address) throws IOException, DeviceException {
        ...
    }

    @Override
    public void turnOn() throws IOException {

    }

    @Override
    public void turnOff() throws IOException {

    }

    @Override
    public boolean isOn() throws IOException {
        return false;
    }

    @Override
    public void setVoltage(double voltage) throws IOException {

    }

    @Override
    public void setCurrent(double current) throws IOException {

    }

    @Override
    public double getVoltage() throws IOException {
        return 0;
    }

    @Override
    public double getCurrent() throws IOException {
        return 0;
    }

}

Multi-Instruments

Most multi-channel instrument types already implement the MultiInstrument interface, and simply require you to implement specific methods for their specific cases. However, if you find yourself needing to implement some instrument that contains multiple sub-instruments that doesn't already have a pre-defined interface, such as MCSMU for multi-channel SMUs, then you will need to implement the MultiChannel interface yourself.

To do this, you define your class as implementing MultiInterface. Then, you will be required to an extra method: getSubInstruments().

public class MyInstrument implements MultiInstrument {

    public List<Instrument> getSubInstruments() {
        return List.of(...);
    }

}

For instance, if our instrument had 8 channels, 4 of them SMU channels, 2 of them voltmeters, and 2 of them voltage sources:

public class MyInstrument implements MultiInstrument, Instrument {

    // Hold sub-instruments as properties of the object
    public final VSource VSU1 = ...;
    public final VSource VSU2 = ...;
    public final VMeter  VMU1 = ...;
    public final VMeter  VMU2 = ...;
    public final SMU     SMU1 = ...;
    public final SMU     SMU2 = ...;
    public final SMU     SMU3 = ...;
    public final SMU     SMU4 = ...;

    // Return list of all sub-instruments
    public List<Instrument> getSubInstruments() {
        return List.of(VSU1, VSU2, VMU1, VMU2, SMU1, SMU2, SMU3, SMU4);
    }

}

This should return a List of all Instrument objects representing the sub-instruments of your instrument. This will then allow for your instrument to be programmatically checked for sub-instruments, for instance by Configurator objects to allow for a user to use one of your instruments sub-instruments for a given purpose. Specifically, these methods are used internally by the object to allow you to query MultiInstrument objects by use of contains(...) (or the in keyword in Kotlin) and to extract sub-instruments by use of get(...) (or by [...] accessors in Kotlin).

For instance, if we had a MultiInstrument and were hoping to either use it, or one of its sub-instruments as an ammeter (IMeter):

Java

MultiInstrument inst = ...;

if (inst instanceof IMeter) {

    /* The instrument itself can be used as an ammeter */

} else if (inst.contains(IMeter.class)) {

    /* The instrument contains at least one ammeter as a sub-instrument */

    // List of all ammeters
    List<IMeter> meters  = inst.get(IMeter.class);    // List of all ammeter sub-instruments
    IMeter       ammeter = inst.get(IMeter.class, n); // Returns nth ammeter sub-instrument   

} else {

    /* Cannot use any part of this instrument as an ammeter */

}

Kotlin

val inst: MultiInstrument = ...

if (inst is IMeter) {

    /* The instrument itself can be used as an ammeter */

} else if (IMeter::class in inst) { 

    /* The instrument contains at least one ammeter as a sub-instrument */

    val meters  = inst[IMeter::class]     // List of all ammeter sub-instruments
    val ammeter = inst[IMeter::class, n]  // Returns nth ammeter sub-instrument

} else {

    /* Cannot use any part of this instrument as an ammeter */

}

If this instrument happened to be of the type we defined above, then the above code would find the SMU channels in the instrument, as they would be the only ones that could be used to perform current measurements (i.e., the only ones that could be cast as IMeter objects).

Exception Types

Generally, your driver should only every throw either an IOException or a DeviceException. You should throw an IOException if there's been some sort of communications error. For example, the connection timed-out or the instrument responded in an unexpected way to a command. For instance, if you ask for whether the instrument is on or off and it responds with something other that 0 or 1:

public boolean isOn() throws IOException {

    int response = queryInt("OUTPUT:ENABLED?");

    switch (response) {

        case 0:
            return false;

        case 1:
            return true;

        default:
            throw new IOException("Invalid response from instrument.");

    }

}

You should throw a DeviceException if the user has tried to perform an action that is not compatible with your instrument. For example, if they try to set a voltage that is outside the range of allowed values. For example, let's say that range is -100 to +100 V:

public void setVoltage(double voltage) throws IOException, DeviceException {

    if (!Util.isBetween(voltage, -100, +100)) {
        throw new DeviceException("Voltage values must be between -100 and +100 Volts");
    }

    write("SOURCE:VOLTAGE %e", voltage);

}

Fully Finished Example:

/**
 *  IB12345 - Instrument driver class for InstrumentBlurg 12345 DC power supplies.
 */
public class IB12345 extends VISADevice implements DCPower {

    public IB12345(Address address) throws IOException, DeviceException {

        super(address);

        // Config options for when connection is over serial
        configSerial(serial -> {

            serial.setSerialParameters(9600, 8);
            setWriteTerminator("\n");
            setReadTerminator("\n");
            setIOLimit(25, true, true);

        });

        // Config options for when connection is over GPIB
        configGPIB(gpib -> {

            gpib.setEOIEnabled(true);
            setIOLimit(10, true, true);

        });

        // Config options for when connection is over TCPIP
        configTCPIP(tcpip -> {

            tcpip.setKeepAliveEnabled(true);
            setWriteTerminator("\n");
            setReadTerminator("\n");

        });

        // Regardless of connection type, we want to remove any trailing LF/CR characters
        addAutoRemove("\n", "\r");

        // Ask instrument for identification
        String idn = query("*IDN?");

        // Check that the returned identification is as expected
        if (!idn.contains("Instrumentblurg Model 12345")) {
            throw new DeviceException("This is not a model 12345!");
        }

    }

    @Override
    public void turnOn() throws IOException {
        write("OUTPUT:ENABLED 1");
    }

    @Override
    public void turnOff() throws IOException {
        write("OUTPUT:ENABLED 0");
    }

    @Override
    public boolean isOn() throws IOException {

        int response = queryInt("OUTPUT:ENABLED?");

        switch (response) {

            case 0:
                return false;

            case 1:
                return true;

            default:
                throw new IOException("Invalid response from IB12345.");

        }

    }

    @Override
    public void setVoltage(double voltage) throws IOException, DeviceExeption {

        if (!Util.isBetween(voltage, -100, +100)) {
            throw new DeviceException("Voltage values must be between -/+100 V");
        }

        write("SOURCE:VOLTAGE %e", voltage);

    }

    @Override
    public void setCurrent(double current) throws IOException, DeviceExeption {

        if (!Util.isBetween(current, -1, +1)) {
            throw new DeviceException("Current values must be between -/+1 A");
        }

        write("SOURCE:CURRENT %e", current);

    }

    @Override
    public double getVoltage() throws IOException {
        return queryDouble("MEASURE:VOLTAGE?");
    }

    @Override
    public double getCurrent() throws IOException {
        return queryDouble("MEASURE:CURRENT?");
    }

}
Clone this wiki locally