A simple device-oriented library for controlling hardware devices in the laboratory.
You may be here for one of two things:
- You want to use a lab device (e.g., A translation stage from Thorlabs, an Ocean Optics spectrometer, a pwoermeter from Gentech-EO), get data, and save it.
- You want to program a driver to get a new device to work on your computer.
If this applies to you, then keep reading. It is not particularly difficult to communicate with USB devices and creating cross-platform drivers is trivial, but you need to understand USB itself.
If you are interested in learning more about:
- USB itself and connectivity details, please read README-USB.md.
- RS-232 and its relation to USB, please read README-RS232.md
- experimenting with an RS-232 chip from FTDI, please read DAQ I/O with UM232R (french only). You can probably Google translate or DeepL translate the Markdown file here.
- USB Cameras, please read README-USB-Cameras.md
- how
PyHardwareLibrarydeals with the many different ports, please read README-Communication ports.md - the process involved in supporting a new device in
PyHardwareLibrarybut also in general, please read README-New-device-coding-example.md
It would be a good plan to read all of the above, essentially in that order.
We often need to control devices in the laboratory (linear stages, spectrometers, cameras, shutters, etc...). The drivers provided by many companies are a good start, but integrating the devices in custom software sometimes gets difficult. This Python module was created to facilitate the development of drivers, facilitate the creation of applications, and provide minimal but useful applications for hardware that is often used in the lab. It originates from a (private) project that I personnally maintained for nearly 10 years where drivers were written in Objective-C and included support for more than 30 different devices used in my laboratory. However, Python is more commonly taught in school and supports essentially all platforms, therefore I started this project so that I can 1) teach how to go about developing simple drivers, 2) teach good programming practices to students, 3) get the hardware working for my own lab regardless of the platforms used (we use macOS and Windows), 4) get help to shorten the development cycles to support more devices.
Why Python? Python is object-oriented (essential) and offers reasonable performance. Python also has the quality of being a very nice team player: it is fairly easy to integrate Python with anything, on any platform and the community is extremely active. It is obvious by the numerous Python SDKs from companies, the thousands of modules on PyPi.org, and the support from all vendors (Microsoft, Apple and Linux). Python is also not a dead language: I am very pleased to see the language evolve over the years with new language features and new standard modules.
The library currently supports the following hardware:
| Category | Device | Class | USB VID:PID | Communication |
|---|---|---|---|---|
| Spectrometers | Ocean Insight USB2000 | USB2000 |
0x2457:0x1002 |
USB (PyUSB) |
| Ocean Insight USB2000+ | USB2000Plus |
0x2457:0x101E |
USB (PyUSB) | |
| Ocean Insight USB4000 | USB4000 |
0x2457:0x1022 |
USB (PyUSB) | |
| Ocean Insight USB650 | USB650 |
0x2457:0x1014 |
USB (PyUSB) | |
| Ocean Insight SAS | SAS |
0x2457:0x1006 |
USB (PyUSB) | |
| StellarNet | StellarNet |
0x0BD7:0xA012 |
USB (licensed module, see note) | |
| Motion (linear) | Sutter MP-285 | SutterDevice |
0x1342:0x0001 |
Serial (FTDI) |
| Thorlabs (Kinesis) | ThorlabsDevice |
0x0403:0xFAF0 |
Kinesis (pylablib) | |
| Motion (rotation) | Intellidrive | IntellidriveDevice |
0x0403:0x6001 |
Serial (FTDI) |
| Laser sources | Cobolt laser | CoboltDevice |
(serial) | Serial |
| Spectra-Physics Millennia eV | MillenniaEv25Device (alias MillenniaDevice) |
0x0483:0x5740 |
Serial (STM32 USB-CDC) | |
| Coherent Verdi G / Genesis (HOPS supply) | VerdiGDevice |
0x0403:0x6010 |
I2C over FTDI (pyftdi) or CohrHOPS.dll |
|
| Sirah Matisse | MatisseDevice |
(TCP) | TCP/IP | |
| Power meters | Gentec-EO Integra | IntegraDevice |
0x1AD5:0x0300 |
USB (PyUSB) |
| Coherent FieldMaster GS | FieldMasterDevice |
0x0403:0x6001 |
Serial (FTDI) | |
| DAQ | LabJack U3 | LabjackDevice |
0x0CD5:0x0003 |
USB (LabJackPython) |
| SRS SR830 lock-in amplifier | SR830Device |
0x0403:0x6001 |
GPIB via Prologix adaptor (serial) | |
| Oscilloscopes | Tektronix TDS series | OscilloscopeDevice |
0x0403:0x6001 |
Serial (FTDI/SCPI) |
| Power strips | PwrUSB switched power strip | PwrUSBDevice |
0x04D8:0x003F |
USB HID (hidapi) |
| Cameras | Any OpenCV camera | OpenCVCamera |
(OS driver) | OpenCV |
Several devices have no USB identity of their own and connect through a generic FTDI RS-232 adaptor (0x0403:0x6001). When more than one such adaptor is plugged in, disambiguate with the adaptor's serialNumber or by passing an explicit portPath.
The StellarNet driver ships encrypted and must be licenced and decrypted by StellarNet; run python -m hardwarelibrary --stellar and enter the password to unlock it.
Every device above also has a debug/simulated counterpart (e.g., DebugLinearMotionDevice, DebugMillenniaDevice, DebugSR830Device, DebugSpectro) that works without hardware, useful for development and testing.
Before using the library, it helps to understand one design decision that shows up everywhere: devices are described by what they can do, not only by what they are. This section explains the idea from scratch; no prior knowledge of object-oriented jargon is assumed.
Look at two lasers from the table above. The Spectra-Physics Millennia can be turned on and off, has a mechanical shutter, and lets you set its output power. The Cobolt can also be turned on and off and lets you set its power, but it has no shutter — and it does have an interlock and an "autostart" mode that the Millennia does not expose. Both are lasers, yet neither is a subset of the other. Motion stages, power meters and DAQ cards are the same story: every model implements a slightly different mix of features.
So how do we write one common interface? Two obvious answers are both bad:
- One big
Laserclass containing every method any laser might have. ThenCoboltDeviceinherits anopenShutter()it cannot honour. Your code can call it, the editor will autocomplete it, and you only discover the truth at runtime when it raises an error — in the middle of an experiment. - One class per combination of features (
LaserWithShutter,LaserWithShutterAndInterlock, ...). The number of classes explodes and nothing is reusable.
We use a third approach. Each individual skill gets its own small class, called a capability:
OnOffCapability— knows aboutturnOn(),turnOff(),isLaserOn()ShutterCapability— knows aboutopenShutter(),closeShutter(),isShutterOpen()PowerCapability— knows aboutsetPower(),power()InterlockCapability,AutostartCapability,WavelengthCapability, ...
A capability is not a device: you can never create one on its own, it has no port, no serial number, and it cannot talk to anything. It is a small, reusable bundle of methods, meant to be mixed into a real device class. That is why such a class is traditionally called a mixin.
In Python a class may inherit from several classes at once, and this is exactly what a driver does. It says "I am a physical device, and I happen to have these skills":
class MillenniaEv25Device(LaserSourceDevice, OnOffCapability, ShutterCapability, PowerCapability):
...
class CoboltDevice(LaserSourceDevice, OnOffCapability, PowerCapability,
InterlockCapability, AutostartCapability):
...Read those two lines as sentences. They are the specification of each laser: the Millennia has on/off, a shutter and power control; the Cobolt has on/off, power, an interlock and autostart. There is no openShutter() on the Cobolt at all — calling it raises AttributeError immediately, instead of pretending to work. The list of parent classes is the documentation, and it cannot go out of date, because it is the code.
Two conventions make this readable at a glance:
- a class whose name ends in
Capabilityis a mixin: a skill, never instantiated by itself; - a class whose name ends in
Deviceis real hardware you can create, connect to, and use.
Each capability provides the public method that you, the user, call — and it delegates the actual hardware work to a companion method whose name starts with do, which the driver author must write. For instance OnOffCapability provides turnOn() and requires doTurnOn():
class MillenniaEv25Device(LaserSourceDevice, OnOffCapability, ShutterCapability, PowerCapability):
def doTurnOn(self):
self.writeActionAndConfirm("ON", "?D", "1", "diodes on") # what this laser expectsThe benefit is a clean division of labour. The public method is written once and is the same for every laser, so it is the name your scripts should use; the driver author only supplies the few lines that are genuinely model-specific. It also gives the library a single place to put behaviour shared by all models, whenever there is any: LinearMotionDevice.moveTo(), for instance, posts a willMove notification, calls doMoveTo(), then posts didMove, so any GUI or logger watching the stage is informed without the driver author writing a line for it (see Listening for device events). Most laser capabilities have nothing to add and simply forward to the do method.
Better still, the do methods are declared abstract, which is Python's way of saying "a subclass must provide this". If you declare ShutterCapability on your new driver but forget doOpenShutter(), Python refuses to even create the object and tells you which method is missing:
TypeError: Can't instantiate abstract class MyLaser without an implementation for abstract method 'doOpenShutter'
That is a mistake caught the first time you run your code, not the day you are aligning an experiment.
This split is uniform across the whole library: every public method of every capability is concrete and delegates, and only the do hooks are abstract. There are no exceptions to remember, and a test enforces it, so the rule cannot quietly erode as drivers are added.
Because the capabilities are ordinary classes, a device can be asked about them at runtime. Every PhysicalDevice offers two methods:
from hardwarelibrary.sources import DebugMillenniaDevice, CoboltDevice, ShutterCapability
laser = DebugMillenniaDevice()
print([c.__name__ for c in laser.capabilities()])
# ['OnOffCapability', 'ShutterCapability', 'PowerCapability']
laser.hasCapability(ShutterCapability) # True
CoboltDevice().hasCapability(ShutterCapability) # FalseThis is what lets you write code that adapts to whatever is on the bench, rather than code that only works with one model:
if laser.hasCapability(ShutterCapability):
laser.closeShutter()
else:
laser.turnOff()A GUI can use the very same trick to decide which buttons to display, and a generic acquisition script can decide whether it is allowed to block the beam without shutting the laser down.
All capabilities are defined in the single module hardwarelibrary/capabilities.py, and they are re-exported by the family they belong to, so you can import them from where you already import the device (from hardwarelibrary.sources import ShutterCapability). Capabilities exist for every family, not just lasers:
| Family | Typical capabilities |
|---|---|
| Laser sources | OnOffCapability, ShutterCapability, PowerCapability, InterlockCapability, AutostartCapability, WavelengthCapability, DispersionCapability |
| DAQ | AnalogInputCapability, AnalogOutputCapability, AnalogIOCapability, AnalogInputStreamCapability, DigitalInputCapability, DigitalOutputCapability, DigitalIOCapability, PhaseLockedDetectionCapability, TriggerCapability |
| Power meters | WavelengthCalibrationCapability, AutoScaleCapability, ScaleCapability |
| Power strips | OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability |
A capability may also be built out of others: AnalogIOCapability is simply AnalogInputCapability plus AnalogOutputCapability, which is why LabjackDevice declares the combined one and gets both sets of methods.
You never have to trust a list in a document to be current: ask the library itself.
python -m hardwarelibrary --capabilitiesThis prints every capability, the capabilities it extends, the public methods it defines, and the do hooks a driver must implement:
OnOffCapability
isLaserOn() -> bool
turnOn()
turnOff()
canTurnOn() -> bool
- hook: doTurnOn()
- hook: doTurnOff()
- hook: doGetOnOffState() -> bool
The same information is available from Python through allCapabilities() and capabilityInterface() in hardwarelibrary/capabilities.py.
To install, download from GitHub then:
pip install .The general usage pattern for any device is the same:
device = SomeDevice() # create
device.initializeDevice() # connect and configure
# ... use the device ...
device.shutdownDevice() # disconnect cleanlyBelow are examples for each device category.
The quickest way to display a live spectrum from any connected Ocean Insight spectrometer:
from hardwarelibrary.spectrometers import OISpectrometer
OISpectrometer.displayAny()To acquire data programmatically:
from hardwarelibrary.spectrometers import USB2000, USB4000
spectro = USB2000() # or USB4000(), USB2000Plus(), USB650()
spectro.initializeDevice()
spectro.setIntegrationTime(100) # milliseconds
spectrum = spectro.getSpectrum() # numpy array
wavelengths = spectro.wavelength # calibrated wavelength axis
spectro.saveSpectrum("measurement.txt")
spectro.shutdownDevice()from hardwarelibrary.motion import SutterDevice
stage = SutterDevice()
stage.initializeDevice()
x, y, z = stage.positionInMicrons() # read current position
stage.moveInMicronsTo((1000, 2000, 0)) # absolute move in µm
stage.moveInMicronsBy((100, 0, 0)) # relative move in µm
stage.home() # return to origin
stage.shutdownDevice()Thorlabs stages use the same LinearMotionDevice interface, driven through
the Thorlabs Kinesis runtime via pylablib
(pip install pylablib):
from hardwarelibrary.motion.thorlabs import ThorlabsDevice
stage = ThorlabsDevice() # routes to the Kinesis backend
stage.initializeDevice()
stage.moveTo((5000, 0, 0))
stage.shutdownDevice()You can also generate a 2D scanning grid of positions:
positions = stage.mapPositions(width=10, height=10, stepInMicrons=5.0)
for info in positions:
stage.moveInMicronsTo(info['position'])
# acquire data at info['index']from hardwarelibrary.motion import IntellidriveDevice
rotator = IntellidriveDevice(serialNumber=".*")
rotator.initializeDevice()
angle = rotator.orientation() # current angle in degrees
rotator.moveTo(90.0) # absolute rotation
rotator.home()
rotator.shutdownDevice()from hardwarelibrary.sources import CoboltDevice
laser = CoboltDevice(portPath="/dev/tty.usbserial-XXXX")
laser.initializeDevice()
laser.turnOn()
laser.setPower(0.050) # 50 mW
print(laser.power()) # read actual output power
print(laser.interlock()) # check interlock state
laser.turnOff()
laser.shutdownDevice()The other lasers use the same methods, minus or plus the ones their capabilities declare. A Millennia or a Verdi adds a shutter, and the Matisse is tuned by wavelength:
from hardwarelibrary.sources import MillenniaDevice, VerdiGDevice, MatisseDevice
pump = MillenniaDevice() # or VerdiGDevice()
pump.initializeDevice()
pump.turnOn()
pump.setPower(5.0) # watts
pump.openShutter() # not available on the Cobolt
pump.closeShutter()
pump.shutdownDevice()
matisse = MatisseDevice(host="192.168.1.10")
matisse.initializeDevice()
matisse.setWavelength(780.0) # nm
print(matisse.wavelength())
matisse.shutdownDevice()from hardwarelibrary.powermeters import IntegraDevice
meter = IntegraDevice()
meter.initializeDevice()
meter.setCalibrationWavelength(532) # nm
power = meter.measureAbsolutePower() # watts
print(f"Power: {power*1000:.2f} mW")
meter.shutdownDevice()from hardwarelibrary.daq import LabjackDevice
daq = LabjackDevice()
daq.initializeDevice()
voltage = daq.getAnalogVoltage(channel=0) # read analog input
daq.setAnalogVoltage(2.5, channel=0) # set analog output
daq.setDigitalValue(True, channel=4) # set digital output
state = daq.getDigitalValue(channel=4) # read digital input
daq.shutdownDevice()from hardwarelibrary.oscilloscope import OscilloscopeDevice
scope = OscilloscopeDevice()
scope.initializeDevice()
scope.displayWaveforms() # live matplotlib display
waveform = scope.getWaveform(channel="CH1") # list of (time, voltage)
scope.shutdownDevice()from hardwarelibrary.powerstrips import PwrUSBDevice
strip = PwrUSBDevice()
strip.initializeDevice()
strip.turnOutletOn(1) # outlets are 1-based, as labelled
strip.turnOutletOff(2)
print(strip.isOutletOn(1)) # True
print(strip.current()) # amperes drawn by the whole strip
strip.shutdownDevice()from hardwarelibrary.cameras import OpenCVCamera
cam = OpenCVCamera()
cam.initializeDevice()
cam.livePreview() # blocking live window
# or
frames = cam.captureFrames(n=5) # capture 5 frames
cam.shutdownDevice()All devices post notifications through the NotificationCenter, so you can observe what the hardware does without polling:
from notificationcenter import NotificationCenter
from hardwarelibrary.capabilities import ShutterNotification
def onShutter(notification):
print("shutter opened on", notification.object)
center = NotificationCenter()
center.add_observer(
observer=self,
method=onShutter,
notification_name=ShutterNotification.didOpenShutter,
observed_object=laser, # omit to hear it from every device
)Every capability posts its own notifications, and you get them for free: a driver only implements the do hooks, and the public method does the announcing. Each capability owns an enum, reachable as ShutterCapability.notification, following three rules:
- an operation that changes the instrument posts
will...before anddid...after, e.g.willOpenShutterthendidOpenShutter; - a read posts only
did..., e.g.didGetPower— bracketing a value that is merely being read would double the traffic on hot paths like a voltage sampled in a loop, for no added information. The exception isSpectrometerNotification.willGetSpectrum, because an acquisition takes an integration time and a display has something to show while it waits; - the
did...is posted whether the operation worked or not, so awill...is always followed by itsdid...and you never have to wonder whether an operation is still running. If the driver raised, the exception continues on its way untouched — your code still sees it — and the notification carries it.
Every one of these operations also requires an initialized device: calling laser.turnOn() before initializeDevice() raises PhysicalDevice.NotInitialized telling you which operation, which device and what state it is in, instead of failing deep inside the driver on a port that was never opened. Nothing is posted in that case, since nothing was attempted. Asking what a model supports (supportedSensitivities(), outletCount) is exempt, so a UI can populate its menus before connecting.
The payload in notification.user_info is a dict of the method's arguments by name, plus "result" and "error". Exactly one of those two is set, which is how an observer tells the outcome:
def onPowerSet(notification):
if notification.user_info["error"] is not None:
log.warning("could not set the power: %s", notification.user_info["error"])
else:
display.update(notification.user_info["power"])Capabilities related by inheritance share one enum, so you never have to know which variant a device mixed in: AnalogInputCapability, AnalogOutputCapability, AnalogIOCapability and AnalogInputStreamCapability all post AnalogNotification, and the three digital ones post DigitalNotification. Observing AnalogNotification.didSetAnalogVoltage catches the event from a LabJack (which mixes in the combined AnalogIOCapability) and from a lock-in amplifier (which mixes in only AnalogOutputCapability) alike.
python -m hardwarelibrary --capabilities prints every capability with the notifications it posts.
The family base classes follow the same scheme, and name their enum in a notification attribute too: LinearMotionNotification (willMove/didMove/didGetPosition), RotationMotionNotification (willMove/didMove/didGetOrientation), PowerMeterNotification (didGetAbsolutePower) and SpectrometerNotification. Motion groups moveTo, moveBy and home under a single willMove/didMove pair, because to an observer they are all "the stage is moving"; the payload tells them apart, carrying a position, a displacement, or neither. The remaining enums are PhysicalDeviceNotification (device lifecycle), CameraDeviceNotification, DeviceControllerNotification and DeviceManagerNotification.
Every device category has a debug class that simulates the hardware in memory:
from hardwarelibrary.motion.linearmotiondevice import DebugLinearMotionDevice
stage = DebugLinearMotionDevice()
stage.initializeDevice()
stage.moveInMicronsTo((100, 200, 0)) # works without any hardware
print(stage.positionInMicrons()) # (100.0, 200.0, 0.0)
stage.shutdownDevice()This is essential for writing and running tests on machines where the physical device is not connected.
All devices inherit from PhysicalDevice, which provides lifecycle management (initialize/shutdown), state tracking, background monitoring, and notification support. Intermediate classes define category-specific interfaces, and the capability mixins described in Capabilities add the per-model features on top:
PhysicalDevice
├── LinearMotionDevice ──── moveTo(), moveBy(), position(), home()
│ ├── SutterDevice
│ ├── ThorlabsDevice
│ │ └── ThorlabsKinesisDevice
│ └── DebugLinearMotionDevice
├── RotationDevice ───────── moveTo(), moveBy(), orientation(), home()
│ └── IntellidriveDevice
├── LaserSourceDevice ────── marker base; the methods come from the capabilities
│ ├── CoboltDevice ─────── OnOff, Power, Interlock, Autostart
│ ├── MillenniaEv25Device OnOff, Shutter, Power
│ └── VerdiGDevice ─────── OnOff, Shutter, Power, Interlock
├── MatisseDevice ────────── Wavelength (a laser, but wired as a PhysicalDevice)
├── PowerMeterDevice ─────── measureAbsolutePower(), setCalibrationWavelength()
│ ├── IntegraDevice
│ └── FieldMasterDevice
├── Spectrometer ─────────── getSpectrum(), setIntegrationTime(), display()
│ ├── OISpectrometer
│ │ ├── USB2000 / USB2000Plus
│ │ ├── USB4000
│ │ ├── USB650
│ │ └── SAS
│ └── StellarNet (licenced module, not distributed)
├── OscilloscopeDevice ───── getWaveform(), displayWaveforms()
├── CameraDevice ─────────── captureFrames(), livePreview(), start(), stop()
│ └── OpenCVCamera
├── PowerStripDevice ─────── OutletSwitching, DefaultOutlet, CurrentMetering
│ └── PwrUSBDevice
├── LabjackDevice ────────── AnalogIO, DigitalIO, AnalogInputStream
└── SR830Device ──────────── AnalogInputStream, AnalogOutput, PhaseLockedDetection, Trigger
Devices communicate through a CommunicationPort abstraction with three backends:
SerialPort— wraps PySerial and PyFTDI for RS-232 and FTDI USB-to-serial devicesUSBPort— wraps PyUSB for direct USB bulk transfers (spectrometers, power meters)DebugPort— in-memory buffers for testing without hardware
On top of these, a command protocol layer (TextCommand, MultilineTextCommand) handles string-based request/response exchanges with regex pattern matching.
Every device follows the same lifecycle managed by PhysicalDevice:
Unconfigured ──initializeDevice()──► Ready ──shutdownDevice()──► Recognized
│ │
└── (failure) ──► Unrecognized └── can re-initialize
The initializeDevice() / shutdownDevice() methods handle housekeeping and post notifications (willInitializeDevice, didInitializeDevice, etc.). Subclasses implement the actual hardware communication in doInitializeDevice() and doShutdownDevice().
Public methods on category classes (e.g., moveTo(), turnOn(), measureAbsolutePower()) handle notifications and validation, then delegate to a do-prefixed method (e.g., doMoveTo(), doTurnOn(), doMeasureAbsolutePower()) that each concrete device overrides. Users call the public methods; do methods are internal.
But maybe your interest is not just in using the devices, but also in learning how to code to control them. You should find extensive documentation here on how to proceed.
You will find a simple, trivial script named cobolt.py to change the power of a Cobolt laser. There are three versions, you should read all three examples :
1-simple: a very trivial implementation with simple commands in sequence2-class: a class implementation ofCoboltLaserthat partially encapsulates the details and exposes a few functions:setPower()andpower()3-class+debugPort: a class implementation with a debug port that mimicks the real device- The main part of the code has a
CoboltDevicethat supportsturnOn()turnOff(),setPower()andpower()
This is just a very simple example with a laser that probably few people have access to, but should give a general idea.
How does one go about supporting a new device? What is the best strategy?
-
Obtain the manual. Look for connectivity information (typically, search for
ASCIIorserialin the text). You will find information such as "baud rate, stop bits, hardware handshake" and most importantly "ASCII or binary commands". This is what you need.- If you can't get the manual from the web site, contact the company. As mentionned above, many will gladly help you: they usually want to sell devices or satisfy customers who did buy them.
-
Connect to the device, one way or another.
- If necessary, a driver may need to be installed to serialize the device (to make it appear as a serial port). In this case, you would use the
SerialPortclass after having installed that driver.- Not all devices can appear as a "serial port". Simple devices (e.g. a translation stage) are fine because they simply read commands ('MOVE") and reply ("OK"). However, others (camera, spectrometers) respond to commands and transmit data, sometime a lot of it and require many communication lines. The USB standard provides that (with endpoints), but not the old-style serial port that essentially is just a two-way communication on a single channel.
- Also, for a device to appear as a serial port, the manufacturer needs to provide a certain amount of information in the USB descriptor of the device. If they don't, you are out of luck.
- If standard serial ports are not available, direct USB access may be needed with
libusbandPyUSB. This is the most elegant solution, but requires some knowledge of USB.PyHardwareLibrarymakes use ofPyUSBextensively, andUSBPortsimplifies communication. - Figure out (ideally through testing, see next point) how to connect with
SerialPortorUSBPort, both derived classes fromCommunicationPort
- If necessary, a driver may need to be installed to serialize the device (to make it appear as a serial port). In this case, you would use the
-
Identify commands and write very simple tests with
SerialPortto confirm connectivity and validate command syntax (see the other section below for more details):class TestCoboltSerialPort(unittest.TestCase): def testLaserOn(self): self.port = SerialPort("COM5") # Are settings right? Baud rate, stop bits, etc... self.port.writeStringExpectMatchingString('l1\r',replyPattern='OK')
-
Create a
DebugSerialPort, based onCommunicationPortreplicating the behaviour ofSerialPort()to mimic a real serial port. SeeCoboltDebugSerialfor an example. -
Complete serial tests that will test both the real port and the debug port. Both must behave identicially.
-
Start wrapping the complex serial communication inside a
PhysicalDevice-derivative (e.g.,LaserSourceDevice,LinearMotionDevice, etc…). For an example, seeCoboltDevicewhich derives fromLaserSourceDevice. For more details on the strategy forPhysicalDevice, see the section :PhysicalDeviceimplementation. -
Write a series of device tests. For examples, see
testCoboltDevice. -
In your device, you must be able to use your
DebugSerialPort. That way, thetestCoboltDevicecan run both on a real device and a debug device. -
When all tests pass (
Port,DebugPort,Device,DebugDevice), you are done
When testing serial ports, we want to test both the real connection to a given device and a mock implementation (e.g, DebugPort) that behaves like it. Hence, we want to run a series of tests on each port. The best strategy to run a series of tests on two different instances is the following:
-
Create a
BaseTestCasesclass that does not inherit fromunittest.TestCase, with an internal class that does inherit fromunittest.TestCases:class BaseTestCases: class TestCoboltSerialPort(unittest.TestCase): self.port = None ...
-
Declare variables that are useful for the test (
self.portfor instance). -
Do not define
setUp()ortearDown() -
Populate the class with all test methods you need, with names that start with
test*:class BaseTestCases: class TestCoboltSerialPort(unittest.TestCase): port = None def testCreate(self): self.assertIsNotNone(self.port) def testCantReopen(self): self.assertTrue(self.port.isOpen) with self.assertRaises(Exception) as context: self.port.open() ...
-
In the same file, define two test subclasses that inherit from
BaseTestCaseswithsetUp()andtearDown()mehods that are specific to either the real port or debug port. They will therefore inherit all the methods from the parent classBaseTestCasesand have all test methods.class TestDebugCoboltSerialPort(BaseTestCases.TestCoboltSerialPort): def setUp(self): self.port = CommunicationPort(port=CoboltDebugSerial()) self.assertIsNotNone(self.port) self.port.open() def tearDown(self): self.port.close() class TestRealCoboltSerialPort(BaseTestCases.TestCoboltSerialPort): def setUp(self): try: self.port = CommunicationPort(port="COM5") self.port.open() except: raise unittest.SkipTest("No cobolt serial port at COM5") def tearDown(self): self.port.close()
-
If you have test methods that are specific to a given port, then define them in the specific class.
-
Add the following at the end of the file:
if __name__ == '__main__': unittest.main()
-
By running the tests in this file with
python testCoboltSerial.py, the unittest framework will automatically run all tests from bothTestDebugCoboltSerialPortandTestRealCoboltSerialPort. Of course, both should pass all tests for success. -
This strategy can be reused to test a
Deviceand itsDebugDevicecounterpart.
Communicating with the device through serial ports is the first step. However, most of the time, we care about some tasks we want to do with the device (turn on and use laser, acquire spectrum from spectrometer, etc...). Therefore, after having figured out what the commands are and how the device responds, it is important to "wrap" or encapsulate all of those commands inside a class (or object) that represents the device to the end-user and make it easy to use without having to know the details. PyHardwareLibrary uses a base class called PhysicalDevice
A real physical device is not simple to handle: errors can occur at any time (because of the device itself), because the user did not connect it or did not turn it on, because the device is in an irregular state (e.g., it reached the end of the travel range for instance). Hence, it becomes important to handle errors gracefully but especially robustly.
The strategy used by the present library is the following:
- Many properties of devices are common: the have a USB vendor ID, a product ID, a serial number etc… This is included in a parent class called
PhysicalDevicethat is the parent to all devices. - Many methods are also common: all devices must be initialized, shutdown, etc… These methods are defined in the parent class, but call the device-specific method of the derived class. For instance,
initializeDevice()does a bit of housekeeping (is the device already initialized? was the underlying initializing successful?) and callsdoInitializeDevicethat must be implemented by the derived class. If initialization fails, it must raise an error. The class must confirm the device responds to at least one internal command to confirm it is indeed the expected device. - For specific classes of devices (e.g.,
LaserSourceDevice), specific methods are used to hide the details of the implementation:LaserSourceDevice.turnOn(),LaserSourceDevice.power(),LaserSourceDevice.setPower(), etc… These methods call device-specific methods with similar names (prefixed bydo) in the derived class (e.g.,doTurnOn()) - Methods that start with
dowill communicate with the device through the serial port. They must store the result of the request into an instance variable (to cache the value and to avoid to go back to the serial port each time the value is needed). For instance, an instanceself.powerstores the result obtained fromdoGetPower(). domethods are never called by users. Users call theturnOn()method but not thedoTurnOn()method. If Python as a language allowed it, thedomethods would be hidden and private, but it does not look possible: the only convention is to use_dobut it is only a convention, functions can still be called.
I must also vent my frustration that end-user software from the manufacturers is often abysmaIly-designed, buggy and/or simply frustrating to use but most of the time, all of the above. I have even seen example code from companies that simply does not even compile. Others will only support Windows 7, and even say it with a straight face in 2021 like it's totally normal. On top of that, many companies will claim (erroneously) that their hardware cannot run on macOS, my platform of choice. This is usually because of shear laziness or straight out incompetence: as long as it can connect to the computer, it can be supported. For USB devices, it is often trivial to write a "driver" to support a device with appropriate documentation, and I have done it on numerous occasions. The rule of thumb is that the companies that have good software say, on Windows, usually have good software on many platforms, as they obviously understand how to program and undertand the simplicity of writing cross-platform code if you make it a design requirement. On the other hand, I have found that lack of support for platforms other than Windows usually translates in fairly crappy software on Windows anyway: these companies tend to be hardware companies that consider software only secondary and probably farm it out. Shout out to ActiveSilicon, Sutter Instruments, Hamamatsu, Ocean Optics Insight Optics (for their excellent protocol documentation but holy mother certainly not for their software, "which is teh suck!"), and Thorlabs for being friendly to developers: they provide all the necessary information upon request and are of great help to scientists. On the other hand, here is a middle finger🖕 to many other companies I will not name here, but many camera providers come to mind (some sell cameras and are located near Princeton University) as well as a prominent company that rhymes with ationalinstruments that wins the grand prize for its uselessness and overall incompetence at providing anything useful in software to their end users for the last 20 years despite producing great hardware (somebody should also let them know that more than 12 pixels can be used to draw icons because this
with a big red x in it apparently represents "automation" and this
is "amplitude modulation". It would be funny if it wasn't so sad).
PyHardwareLibrary is therefore a personnel project to get around those missing, buggy, awkward, poorly-designed, unsupported, slow, unusable drivers and libraries from vendors and also a teaching tool for myself and others.
Prof. Daniel Côté, Ph.D. and P.Eng, dccote@cervo.ulaval.ca
Group web site: http://www.dccmlab.ca
Youtube channel: http://www.youtube.com/user/dccote