Metadata-Version: 2.4
Name: hardwarelibrary
Version: 2.1.0
Summary: Cross-platform (macOS, Windows, Linux, etc...) library to control various hardware devices mostly for scientific applications.
Author-email: Daniel Cote <dccote@cervo.ulaval.ca>
License: MIT
Project-URL: Homepage, https://github.com/DCC-Lab/PyHardwareLibrary
Keywords: hardware,devices,usb,communication,app,control,spectrometer,powermeter,camera
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Education
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Education
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: notifcenter
Requires-Dist: numpy
Requires-Dist: matplotlib
Requires-Dist: pyserial
Requires-Dist: pyusb
Requires-Dist: pyftdi
Requires-Dist: LabJackPython
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx; extra == "docs"
Requires-Dist: furo; extra == "docs"
Requires-Dist: myst-parser; extra == "docs"
Requires-Dist: sphinxcontrib-mermaid; extra == "docs"
Provides-Extra: thorlabs
Requires-Dist: pylablib; extra == "thorlabs"
Provides-Extra: pwrusb
Requires-Dist: hidapi; extra == "pwrusb"

# PyHardwareLibrary

A simple device-oriented library for controlling hardware devices in the laboratory.

You may be here for one of two things:

1. 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.
2. 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.

## Learning more

If you are interested in learning more about:

* **USB itself** and connectivity details, please read [README-USB.md](README-1-USB.md).
* **RS-232 and its relation to USB**, please read [README-RS232.md](README-2-RS232.md)
* **experimenting with an RS-232 chip from FTDI**, please read [DAQ I/O with UM232R (french only)](https://github.com/dccote/Enseignement/blob/4c8eca0c7ca7ec74c60774af57483f5d77fb60be/DAQ/Semaine-02.pdf). You can probably Google translate or DeepL translate the Markdown file [here](https://github.com/dccote/Enseignement/blob/4c8eca0c7ca7ec74c60774af57483f5d77fb60be/DAQ/Semaine-02.md).
* **USB Cameras**, please read [README-USB-Cameras.md](README-3-USB-Cameras.md)
* how `PyHardwareLibrary` **deals with the many different ports**, please read [README-Communication ports.md](README-5-Communication-ports.md)
* **the process involved in supporting a new device** in  `PyHardwareLibrary` but also in general, please read [README-New-device-coding-example.md](README-4-New-device-coding-example.md)

It would be a good plan to read all of the above, essentially in that order.

## What is the purpose of this hardware library?

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. 

## Supported devices

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.

## Capabilities: what a device can actually do

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.

### The problem

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 `Laser` class containing every method any laser might have.** Then `CoboltDevice` inherits an `openShutter()` 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.

### The solution: small classes, one skill each

We use a third approach. Each individual skill gets its own small class, called a **capability**:

* `OnOffCapability` — knows about `turnOn()`, `turnOff()`, `isLaserOn()`
* `ShutterCapability` — knows about `openShutter()`, `closeShutter()`, `isShutterOpen()`
* `PowerCapability` — knows about `setPower()`, `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":

```python
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 **`Capability`** is a mixin: a skill, never instantiated by itself;
* a class whose name ends in **`Device`** is real hardware you can create, connect to, and use.

### Who writes what: public methods and `do` hooks

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()`:

```python
class MillenniaEv25Device(LaserSourceDevice, OnOffCapability, ShutterCapability, PowerCapability):
    def doTurnOn(self):
        self.writeActionAndConfirm("ON", "?D", "1", "diodes on")   # what this laser expects
```

The 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](#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.

### Asking a device what it can do

Because the capabilities are ordinary classes, a device can be asked about them at runtime. Every `PhysicalDevice` offers two methods:

```python
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) # False
```

This is what lets you write code that adapts to whatever is on the bench, rather than code that only works with one model:

```python
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.

### Where they live

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.

```shell
python -m hardwarelibrary --capabilities
```

This 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`.

## Getting started with using devices

To install, [download](https://github.com/DCC-Lab/PyHardwareLibrary/archive/refs/heads/master.zip) from GitHub then:

```shell
pip install .
```

The general usage pattern for any device is the same:

```python
device = SomeDevice()          # create
device.initializeDevice()      # connect and configure
# ... use the device ...
device.shutdownDevice()        # disconnect cleanly
```

Below are examples for each device category.

### Spectrometers (Ocean Insight)

The quickest way to display a live spectrum from any connected Ocean Insight spectrometer:

```python
from hardwarelibrary.spectrometers import OISpectrometer

OISpectrometer.displayAny()
```

To acquire data programmatically:

```python
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()
```

### Linear motion stages (Sutter, Thorlabs)

```python
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](https://pylablib.readthedocs.io)
(`pip install pylablib`):

```python
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:

```python
positions = stage.mapPositions(width=10, height=10, stepInMicrons=5.0)
for info in positions:
    stage.moveInMicronsTo(info['position'])
    # acquire data at info['index']
```

### Rotation stages (Intellidrive)

```python
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()
```

### Laser sources (Cobolt)

```python
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:

```python
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()
```

### Power meters (Gentec-EO Integra)

```python
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()
```

### Data acquisition (LabJack U3)

```python
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()
```

### Oscilloscopes (Tektronix TDS)

```python
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()
```

### Power strips (PwrUSB)

```python
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()
```

### Cameras (OpenCV)

```python
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()
```

### Listening for device events

All devices post notifications through the `NotificationCenter`, so you can observe what the hardware does without polling:

```python
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 and `did...` after, e.g. `willOpenShutter` then `didOpenShutter`;
* 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 is `SpectrometerNotification.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 a `will...` is always followed by its `did...` 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:

```python
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`.

### Testing without hardware

Every device category has a debug class that simulates the hardware in memory:

```python
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.

## Architecture

### Class hierarchy

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](#capabilities-what-a-device-can-actually-do) 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
```

### Communication layer

Devices communicate through a `CommunicationPort` abstraction with three backends:

- **`SerialPort`** — wraps PySerial and PyFTDI for RS-232 and FTDI USB-to-serial devices
- **`USBPort`** — 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.

### Device lifecycle

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()`.

### Template method pattern

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.

## Getting started with coding for new devices

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. `1-simple`: a very trivial implementation with simple commands in sequence
2. `2-class`: a class implementation of `CoboltLaser` that partially encapsulates the details and exposes a few functions: `setPower()` and `power()`
3. `3-class+debugPort`: a class implementation with a debug port that mimicks the real device
4. The main part of the code has a `CoboltDevice` that supports `turnOn()` `turnOff()`, `setPower()` and `power()`

This is just a very simple example with a laser that probably few people have access to, but should give a general idea.

### Strategy

How does one go about supporting a new device? What is the best strategy?

1. Obtain the manual.  Look for connectivity information (typically, search for `ASCII` or `serial` in 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.

   1. 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.
   
2. Connect to the device, one way or another.

   1. 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 `SerialPort` class after having installed that driver. 
      1. 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. 
      2. 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.
   2. If standard serial ports are not available, direct USB access may be needed with `libusb` and `PyUSB`.  This is the most elegant solution, but requires some knowledge of USB.  `PyHardwareLibrary` makes use of `PyUSB` extensively, and `USBPort` simplifies communication.
   3. Figure out (ideally through testing, see next point) how to connect with `SerialPort` or `USBPort`, both derived classes from `CommunicationPort`

3. Identify commands and write very simple tests with `SerialPort`  to confirm connectivity and validate command syntax (see the other [section](#Testing-serial-ports) below for more details):

   ```python
       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')
   
   ```

4. Create a `DebugSerialPort`, based on `CommunicationPort`  replicating the behaviour of `SerialPort()` to mimic a real serial port.  See `CoboltDebugSerial` for an example.

5. Complete *serial* tests that will test both the real port and the debug port. Both must behave identicially.

6. Start wrapping the complex serial communication inside a `PhysicalDevice`-derivative (e.g., `LaserSourceDevice`, `LinearMotionDevice`, etc…). For an example, see `CoboltDevice` which derives from `LaserSourceDevice`.  For more details on the strategy for `PhysicalDevice`, see the section : `PhysicalDevice` implementation.

7. Write a series of device tests.  For examples, see `testCoboltDevice`.

8. In your device, you must be able to use your `DebugSerialPort`.  That way, the `testCoboltDevice` can run both on a real device and a debug device.

9. When all tests pass (`Port`, `DebugPort`, `Device`, `DebugDevice`), you are done

### Testing serial ports

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:

1. Create a `BaseTestCases` class that does not inherit from `unittest.TestCase`, with an internal class that does inherit from `unittest.TestCases`:

   ```python
   class BaseTestCases:

      class TestCoboltSerialPort(unittest.TestCase):
         self.port = None

         ...
   ```


2. Declare variables that are useful for the test (`self.port` for instance).

3. Do not define `setUp()` or `tearDown()`

4. Populate the class with all test methods you need, with names that start with `test*`:
   ```python
   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()
        ...
   ```

5. In the same file, define two test subclasses that inherit from `BaseTestCases` with `setUp()` and `tearDown()` mehods that are specific to either the real port or debug port. They will therefore inherit all the methods from the parent class `BaseTestCases` and have all test methods.

   ```python
   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()
   ```

6. If you have test methods that are specific to a given port, then define them in the specific class.

7. Add the following at the end of the file:

   ```python
   if __name__ == '__main__':
       unittest.main()
   ```

8. By running the tests in this file with `python testCoboltSerial.py`, the unittest framework will automatically run all tests from both `TestDebugCoboltSerialPort` and `TestRealCoboltSerialPort`.  Of course, both should pass all tests for success.

9. This strategy can be reused to test a `Device` and its `DebugDevice` counterpart.



## Design goals

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:

1. 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 `PhysicalDevice` that is the parent to all devices.
2. 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 calls `doInitializeDevice` that 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.
3. 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 by `do`) in the derived class (e.g., `doTurnOn()`)
4. Methods that start with `do` *will 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 instance `self.power` stores the result obtained from `doGetPower()`.
5. `do` methods are *never* called by users.  Users call the `turnOn()` method but not the `doTurnOn()` method. If Python as a language allowed it, the `do` methods would be hidden and private, but it does not look possible: the only convention is to use `_do` but it is only a convention, functions can still be called.



## Motivation

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 <img src="README.assets/automation.png" alt="automation" style="zoom:200%;" /> with a big red x in it apparently represents "automation" and this <img src="README.assets/Am.png" alt="Am" style="zoom:200%;" /> 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.

## Contact

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

