← Back to projects

Thorlabs Cube Drivers

Python device drivers for quantum optics motor controllers with network accessibility

2024python, quantum-computing, hardware-drivers, networking

View on GitHub


The Problem

Thorlabs motor controllers are ubiquitous in quantum optics labs. They control beam shutters, mirror mounts, rotation stages, and other precision optical components. However, they come with a significant limitation: Thorlabs' proprietary Kinesis software.

Kinesis works fine for manual control, but it fails in three critical areas:

  • No programmatic control — You can't script complex sequences or integrate with experiment control systems
  • No network accessibility — Controllers are "network-deaf," requiring direct USB connection to a Windows PC
  • No integration with ARTIQ — The de facto standard for quantum experiment control has no native Thorlabs support

For a quantum computing lab running dozens of Thorlabs devices across multiple optical tables, this is a serious operational bottleneck.


The Solution

I developed a Python driver package that:

  • Communicates directly with Thorlabs T-Cube and K-Cube controllers via their APT protocol
  • Exposes devices as Network Device Support Package (NDSP) servers
  • Integrates seamlessly with the ARTIQ experiment control framework

This transforms isolated USB devices into networked instruments that can be controlled from anywhere in the lab.


Technical Architecture

The APT Protocol

Thorlabs controllers use the APT (Advanced Positioning Technology) protocol over USB serial. The protocol is documented but not trivial—it uses a binary message format with headers, destinations, and payload structures.

Message structure:

| Header (6 bytes) | Data (variable) |
| msg_id | param1 | param2 | dest | source | data... |

Key challenges:

  • Endianness: Mixed little-endian and big-endian fields
  • Device identification: Multiple device types share the same USB VID/PID
  • State management: Some commands require specific controller states

Example: Opening a beam shutter

# Message breakdown for MGMSG_MOT_MOVE_JOG
msg_id = 0x046A      # Jog command
chan_ident = 0x01    # Channel 1
direction = 0x01     # Forward
dest = 0x50          # Generic USB destination
source = 0x01        # Host controller

The driver abstracts this complexity into a clean Python API:

shutter = SH05R("/dev/ttyUSB0")
shutter.open()
shutter.close()

Driver Architecture

I designed the driver package with separation of concerns:

thorlabs_cube/
├── protocol/
│   ├── apt.py          # Low-level APT message encoding/decoding
│   └── serial.py       # Serial port management
├── devices/
│   ├── base.py         # Abstract device class
│   ├── ksc101.py       # K-Cube solenoid controller
│   ├── kdc101.py       # K-Cube DC servo controller
│   └── tdc001.py       # T-Cube DC servo controller
├── ndsp/
│   ├── server.py       # NDSP server implementation
│   └── client.py       # NDSP client for remote access
└── utils/
    └── discovery.py    # USB device enumeration

Design decisions:

DecisionRationale
Abstract base class for devicesCommon interface, easy to add new device types
Separate protocol layerReusable across device types, testable in isolation
Async I/O for NDSPNon-blocking server, handles multiple clients
Type hints throughoutBetter IDE support, catches errors early

Network Device Support Package (NDSP)

Why NDSP?

ARTIQ uses a client-server model for device control. The experiment controller (master) communicates with device servers (NDSPs) over the network. Each NDSP exposes a device's functionality via RPC.

Before NDSP:

[Experiment PC] --USB--> [Thorlabs Controller]
Problem: Experiment PC must be physically near the controller

After NDSP:

[Experiment PC] --Network--> [NDSP Server] --USB--> [Thorlabs Controller]
Solution: Controller can be anywhere on the network

Implementation

The NDSP server exposes device methods as RPC endpoints:

class SH05RNDSP:
    def __init__(self, device_path):
        self.device = SH05R(device_path)
def open_shutter(self):
    """RPC method: Opens the beam shutter."""
    self.device.open()
    return True
def close_shutter(self):
    """RPC method: Closes the beam shutter."""
    self.device.close()
    return True
def get_state(self):
    """RPC method: Returns current shutter state."""
    return self.device.state

ARTIQ integration:

# In device_db.py
device_db = {
    "shutter": {
        "type": "controller",
        "host": "192.168.1.100",
        "port": 3251,
        "command": "aqctl_thorlabs_sh05r -p /dev/ttyUSB0"
    }
}
# In experiment
class MyExperiment(EnvExperiment):
    def build(self):
        self.setattr_device("shutter")
def run(self):
    self.shutter.open_shutter()
    delay(100*ms)
    self.shutter.close_shutter()

Challenges and Solutions

Challenge 1: USB Device Discovery

Problem: Multiple Thorlabs controllers have the same USB vendor/product ID. When you plug in three K-Cube controllers, how do you know which is which?

Solution: Query the device serial number via APT protocol after connection. I implemented an auto-discovery function that:

  • Enumerates all USB serial devices matching Thorlabs VID/PID
  • Opens each device and sends an identification request
  • Parses the response to get model and serial number
  • Returns a mapping of serial numbers to device paths
devices = discover_thorlabs_devices()
# Returns: {"27123456": "/dev/ttyUSB0", "27123457": "/dev/ttyUSB1"}

Challenge 2: Timing-Sensitive Commands

Problem: Some motor movements require precise timing. Network latency can introduce jitter.

Solution: Implemented command queuing on the NDSP server side. The server maintains a command queue with timestamps, executing commands at the correct relative times regardless of when they arrived over the network.

For sub-millisecond timing requirements, I added a "burst mode" that downloads a sequence of commands to the NDSP server and executes them locally with hardware timing.

Challenge 3: Error Handling Across Network Boundary

Problem: If the USB device disconnects, how does the remote client know?

Solution: Implemented heartbeat monitoring and automatic reconnection:

  • NDSP server pings device every 5 seconds
  • If 3 consecutive pings fail, mark device as disconnected
  • RPC calls to disconnected devices raise DeviceOfflineError
  • Background thread attempts reconnection every 10 seconds
  • Clients can subscribe to connection state changes

Testing Strategy

Unit Tests

I used pytest with mock serial devices to test the protocol layer:

def test_apt_message_encoding():
    msg = APTMessage(MGMSG_MOT_MOVE_JOG, chan=1, direction=1)
    encoded = msg.encode()
    assert encoded == bytes([0x6A, 0x04, 0x01, 0x01, 0x50, 0x01])

Integration Tests

Hardware-in-the-loop tests with actual Thorlabs controllers:

@pytest.mark.hardware
def test_shutter_open_close():
    shutter = SH05R("/dev/ttyUSB0")
    shutter.open()
    assert shutter.state == "open"
    shutter.close()
    assert shutter.state == "closed"

Network Tests

End-to-end tests with NDSP server and client:

def test_ndsp_rpc():
    # Start server in subprocess
    server = start_ndsp_server("SH05R", "/dev/ttyUSB0", port=3251)
# Connect client
client = NDSPClient("localhost", 3251)
client.open_shutter()
assert client.get_state() == "open"
server.terminate()

Results

Packages Released

I released 4 driver packages for the lab:

DeviceDescriptionStatus
KSC101K-Cube solenoid controller (beam shutters)Production
KDC101K-Cube DC servo (rotation stages)Production
TDC001T-Cube DC servo (translation stages)Production
KBD101K-Cube brushless DC (mirror mounts)Beta

Impact

  • Eliminated Windows dependency — Controllers now accessible from Linux experiment PCs
  • Enabled remote operation — Operators can control shutters from the control room
  • Reduced cabling — Single Ethernet drop per optical table instead of multiple USB runs
  • Integrated with ARTIQ — Beam shutters now synchronized with ion trap pulse sequences

Cost Savings

The lab was considering purchasing network-enabled motor controllers at ~$500 premium per unit. With NDSP:

Existing controllers: 12 units
Premium avoided: 12 × $500 = $6,000
Raspberry Pi hosts: 3 × $50 = $150
Net savings: $5,850

Lessons Learned

What Worked

  • Starting with the protocol — Understanding APT deeply made device implementation straightforward
  • NDSP abstraction — Network transparency is powerful; clients don't care where the device is
  • Comprehensive logging — Debugging hardware issues is much easier with detailed logs

What I'd Improve

  • Add async support throughout — Currently mixing sync serial with async NDSP
  • Better documentation — Would add interactive examples in Jupyter notebooks
  • Configuration files — Currently hardcoding device paths; should use YAML/TOML config

Technical Specifications

ComponentDetails
LanguagePython 3.9+
Dependenciespyserial, sipyco (ARTIQ), asyncio
Supported devicesKSC101, KDC101, TDC001, KBD101
ProtocolThorlabs APT over USB serial
NetworkTCP/IP, ARTIQ NDSP RPC
Latency<5ms local, <20ms network
PlatformsLinux, macOS, Windows

Conclusion

This project solved a real pain point in the quantum optics lab: making "dumb" USB devices network-accessible and programmable. By understanding the underlying APT protocol and leveraging ARTIQ's NDSP framework, I was able to transform isolated controllers into networked instruments.

The key insight was that the protocol layer is separate from the network layer. Once I had reliable APT communication, exposing it via NDSP was straightforward. This architecture is extensible—adding support for new Thorlabs devices requires only implementing the device-specific APT commands.

More broadly, this project reinforced the value of understanding systems deeply. The Thorlabs protocol documentation is sparse, but by reverse-engineering Kinesis traffic and reading the APT specification carefully, I was able to build something more flexible than the official software.