| name | pyhardwarelibrary |
| description | Control lab hardware with the PyHardwareLibrary Python package — motion stages, spectrometers, lasers, power meters, DAQs, oscilloscopes, and USB power strips. Use when a user wants to move a stage, acquire a spectrum, turn a laser on/off or set its power/wavelength, read a power meter, read/write DAQ voltages, switch power-strip outlets on/off, discover connected devices, run code without hardware (debug devices), or build a headless/GUI controller around a device. Covers the device lifecycle, per-family public API, event notifications, and the DeviceController worker-thread wrapper. |
Using PyHardwareLibrary
PyHardwareLibrary is a device-oriented library: every instrument is a PhysicalDevice
subclass with a uniform lifecycle and a small, predictable public API per family. This
skill is for using devices to get work done (move, measure, set). To write a new
driver, read CLAUDE.md and README-4-New-device-coding-example.md instead.
Developers: always pull master from origin before starting. This repository evolves
rapidly and its architecture shifts under you — capability mixins were recently renamed
(*Control -> *Capability) and consolidated into a single hardwarelibrary/capabilities.py.
Branch off an up-to-date master (git pull first) so you build against the current
conventions, not a stale snapshot.
Use the primitives from CommunicationPort for all communications. A driver's do*
methods talk to hardware only through self.port — writeData / readData (and the
readString / writeString / writeStringReadMatch helpers built on them). Do not
invent new send/receive helpers on the device (no _sendCommandByte, no _query); call
the port methods directly. To support a new transport (serial, USB, HID, TCP, ...),
subclass CommunicationPort and go through the expected mechanism of overriding
readData and writeData (plus open / close / isOpen / flush) — the string and
transaction helpers then compose on top for free. HIDPort is the reference example.
The one rule: lifecycle
Every device follows the same pattern. Construct it, initialize it, use it, shut it down.
from hardwarelibrary.motion.sutterdevice import SutterDevice
stage = SutterDevice()
stage.initializeDevice()
try:
stage.moveTo((1000, 2000, 0))
print(stage.position())
finally:
stage.shutdownDevice()
initializeDevice() opens the connection and raises PhysicalDevice.UnableToInitialize
if the device is absent/busy. Nothing works before it.
shutdownDevice() closes the port. Always call it (use try/finally).
- Calling a command before
initializeDevice() raises PhysicalDevice.NotInitialized.
Finding / constructing a device
There is no reliable generic auto-discovery — do not rely on PhysicalDevice.any()
(it is incomplete and returns nothing). Use one of these instead:
- Spectrometers have working discovery:
Spectrometer.any() returns the first
supported spectrometer, ready to use.
- Everything else: construct the concrete class directly. USB devices match by
serial number (default
None/"*" = first found); serial/TCP devices take a port
path. See each family below for the exact constructor.
Run without hardware (debug devices)
Most families ship a DebugXxxDevice that emulates the protocol in memory, so examples
and tests run with nothing plugged in. They obey the same lifecycle and API.
from hardwarelibrary.motion.linearmotiondevice import DebugLinearMotionDevice
from hardwarelibrary.daq.labjackdevice import DebugLabjackDevice
from hardwarelibrary.sources.millennia import DebugMillenniaEv25Device
stage = DebugLinearMotionDevice()
stage.initializeDevice()
stage.moveTo((10, 20, 30))
print(stage.position())
stage.shutdownDevice()
Family quick reference
Linear motion stages — Sutter MP-285, Thorlabs (Kinesis)
Base: LinearMotionDevice. Positions are 3-tuples (x, y, z) in native steps.
from hardwarelibrary.motion.sutterdevice import SutterDevice
stage = SutterDevice(serialNumber=None)
stage.initializeDevice()
stage.moveTo((x, y, z))
stage.moveBy((dx, dy, dz))
pos = stage.position()
stage.home()
stage.moveInMicronsTo((x_um, y_um, z_um))
stage.moveInMicronsBy((dx_um, dy_um, dz_um))
posUm = stage.positionInMicrons()
for point in stage.mapPositions(width, height, stepInMicrons):
stage.moveInMicronsTo(point["position"])
stage.shutdownDevice()
Rotation stages — Intellidrive
Base: RotationDevice. Note IntellidriveDevice(serialNumber) requires the serial number.
from hardwarelibrary.motion.intellidrivedevice import IntellidriveDevice
rot = IntellidriveDevice(serialNumber="...")
rot.initializeDevice()
rot.moveTo(angle)
rot.moveBy(deltaTheta)
theta = rot.orientation()
rot.home()
rot.shutdownDevice()
Spectrometers — Ocean Insight USB2000 / USB2000+ / USB4000 / USB650 / SAS
Base: Spectrometer. The public method is the hardware hook (no do* wrapper).
from hardwarelibrary.spectrometers import Spectrometer
spectrometer = Spectrometer.any()
spectrometer.initializeDevice()
spectrometer.setIntegrationTime(50)
spectrum = spectrometer.getSpectrum()
print(spectrometer.getSerialNumber())
spectrometer.saveSpectrum("scan.csv")
spectrometer.shutdownDevice()
Laser sources — Cobolt, Spectra-Physics Millennia eV, Sirah Matisse
Base: LaserSourceDevice plus capability mixins. A device only has the methods of
the capabilities it declares — check the table.
| Capability | Methods |
|---|
OnOffCapability | turnOn(), turnOff(), isLaserOn(), canTurnOn() |
ShutterCapability | openShutter(), closeShutter(), isShutterOpen() |
PowerCapability | setPower(watts), power() |
InterlockCapability | interlock() |
WavelengthCapability | setWavelength(nm), wavelength(), wavelengthRange() |
from hardwarelibrary.sources.millennia import MillenniaEv25Device
laser = MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX")
laser.initializeDevice()
laser.setPower(5.0)
laser.turnOn()
laser.openShutter()
print(laser.power(), laser.isLaserOn(), laser.isShutterOpen())
laser.closeShutter()
laser.turnOff()
laser.shutdownDevice()
- Cobolt:
CoboltDevice(portPath="COM3") — OnOff + Power (+ autostart constraints; it
may refuse turnOn() when autostart is on, raising CoboltCantTurnOnWithAutostartOn).
- Matisse:
MatisseDevice(...) over TCP — WavelengthCapability (setWavelength/wavelength)
plus BiFi/etalon/piezo/scan methods.
Power meters — Gentec-EO Integra
Base: PowerMeterDevice.
from hardwarelibrary.powermeters import IntegraDevice
meter = IntegraDevice()
meter.initializeDevice()
meter.setCalibrationWavelength(800)
watts = meter.measureAbsolutePower()
meter.shutdownDevice()
DAQ — LabJack U3
Combines capability mixins: analog/digital in/out, plus hardware-timed input.
from hardwarelibrary.daq.labjackdevice import LabjackDevice
daq = LabjackDevice()
daq.initializeDevice()
v = daq.getAnalogVoltage(channel=0)
daq.setAnalogVoltage(2.5, channel=1)
bit = daq.getDigitalValue(channel=4)
daq.setDigitalValue(1, channel=5)
daq.shutdownDevice()
Power strips — PwrUSB / PowerUSB
Base: PowerStripDevice plus capability mixins (like lasers/DAQ). Outlets are addressed
1-based to match the physical labels. A device only has the methods of the capabilities
it declares.
| Capability | Methods |
|---|
OutletSwitchingCapability | turnOutletOn(n), turnOutletOff(n), setOutletState(n, isOn), isOutletOn(n), outletCount |
DefaultOutletCapability | setOutletDefaultOn(n), setOutletDefaultOff(n), setOutletDefaultState(n, isOn) |
CurrentMeteringCapability | current() (A), accumulatedCharge() (Ah), resetAccumulatedCharge() |
from hardwarelibrary.powerstrips import PwrUSBDevice
strip = PwrUSBDevice()
strip.initializeDevice()
strip.turnOutletOn(1)
print(strip.isOutletOn(1), "of", strip.outletCount)
strip.setOutletDefaultOff(1)
strip.turnOutletOff(1)
strip.shutdownDevice()
- USB HID only (
04d8:003f), driven over hidapi (HIDPort). The OS claims the HID
interface (on macOS SerialPort has no /dev node and USBPort/libusb can't open it),
so control needs the hidapi extra: pip install -e .[pwrusb] (PyPI package hidapi,
imports as hid — not the different hid package).
- Outlet state is cached on write (live readback is unreliable on this firmware), so
isOutletOn(n) returns the last commanded state.
- Metering is only meaningful on the "Smart" model; a "Basic" unit returns
non-measurement values from
current() / accumulatedCharge().
- No hardware?
DebugPwrUSBDevice() emulates the protocol in memory.
Oscilloscopes — Tektronix TDS
Instantiated directly (no family/driver split); methods are SCPI per instrument.
from hardwarelibrary.oscilloscope import OscilloscopeDevice
scope = OscilloscopeDevice()
scope.initializeDevice()
scope.shutdownDevice()
Reacting to events (NotificationCenter)
Devices post Cocoa-style notifications (state changes, measurements, moves) instead of
requiring polling. Observe them from anywhere:
from hardwarelibrary.notificationcenter import NotificationCenter
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification
def onMeasure(notification):
print("power:", notification.userInfo)
NotificationCenter().addObserver(self, onMeasure, PowerMeterNotification.didMeasure)
NotificationCenter().removeObserver(self)
Useful notification enums: PhysicalDeviceNotification (will/did initialize/shutdown,
status), LinearMotionNotification (willMove/didMove/didGetPosition),
PowerMeterNotification.didMeasure.
Headless / GUI apps: DeviceController
For an app (GUI, long-running service) wrap the device in a DeviceController. It runs
all device access on one worker thread, so blocking calls never freeze a UI and port
access is serialized. It auto-reconnects and reports through NotificationCenter.
from hardwarelibrary.devicecontroller import (
DeviceController, DeviceControllerNotification as N)
from hardwarelibrary.notificationcenter import NotificationCenter
from hardwarelibrary.sources.millennia import MillenniaEv25Device
controller = DeviceController(MillenniaEv25Device(portPath="/dev/cu.usbmodemXXXX"))
NotificationCenter().addObserver(self, lambda n: print(n.userInfo), N.status)
controller.start()
controller.connect()
controller.submit(lambda device: device.turnOn())
reading = controller.submit(lambda device: device.power()).result()
controller.stop()
submit(action) runs action(device) on the worker and returns a
concurrent.futures.Future carrying the result or the exception. Failures also post a
commandFailed notification; drops post connectionLost/connectionFailed.
Gotchas
- Always
initializeDevice() before use, and shutdownDevice() after — wrap in
try/finally. A leaked open port blocks the next run with a "resource busy" error.
PhysicalDevice.any() / anyDevice() are incomplete — only Spectrometer.any()
returns a usable device. For other families, construct the concrete class.
DeviceManager is not fully operational — prefer the per-family approach above.
- No hardware? Use the
DebugXxxDevice for that family to develop and test.
- The version is git-tag-derived (
setuptools-scm); read CHANGELOG.md, because
API changes can land even when the minor version is unchanged.