-
Notifications
You must be signed in to change notification settings - Fork 9
Writing new 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.
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:
- Via text sent over GPIB/serial/TCP-IP/USB etc. So-called "VISA" type communication.
- Using an industry standard called MODBUS-RTU.
- 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.
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);
}
}
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).
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.
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);
}
}
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);
}
}
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.
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.
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).
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;
}
}
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).
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);
}
/**
* 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?");
}
}
- Getting Started
- Object Orientation
- Choosing a Language
- Using JISA in Java
- Using JISA in Python
- Using JISA in Kotlin
- Exceptions
- Functions as Objects
- Instrument Basics
- SMUs
- Thermometers (and old TCs)
- PID and Temperature Controllers
- Lock-Ins
- Power Supplies
- Pre-Amplifiers
- Writing New Drivers