Skip to content

USB-only driver for Santec SLMs - #193

Open
spomjaksilp wants to merge 17 commits into
holodyne:mainfrom
spomjaksilp:hardware/santec_usb
Open

USB-only driver for Santec SLMs#193
spomjaksilp wants to merge 17 commits into
holodyne:mainfrom
spomjaksilp:hardware/santec_usb

Conversation

@spomjaksilp

Copy link
Copy Markdown

Description

This MR adds an alternative driver SantecUSB for Santec SLMs purely based on usb communication following the "Memory Mode" workflow in the Santec manual (p. 9). Instead of using the .dll libs from Santec, the code leverages direct communication to the FTDI chip inside the controller box via PyD3XX. hardware.slms.santec.SantecUSB is designed to be a separate access path for the SLM, the dll-based implementation is accessible via hardware.slms.santec.Santec. To this end, both classes are now wrapped into the hardware.slms.santec submodule.

Rationale

  1. Santec does not provide a linux library (dll is windows only)
  2. the current implementation uses the DVI path for image upload hence one always needs 2 cables
  3. using multiple slots is not possible via DVI image upload
  4. I really want to use the device on a linux system 😁

Technical

The underlying SantecFTDI (living in _santec_ftdi.py) is the low-level USB protocol layer that handles the hardware communication via PyD3XX.
The inner workings of SantecFTDI where gathered by sniffing and reverse engineering the original dll calls via FT_WritePipe / FT_ReadPipe in D3XX.dll.

Known Limitations

As stated in the manual, setting the phase pattern via the internal memory is slower compared to the update rate via DVI. Yet, this opens the path to using multiple slots and quickly change between them via an external trigger within realtime timing constraints.
The methods for that still need to be implemented and tested, but the groundwork is there.

Code

  • the code uses numpy style docstrings and python 3.11+ type annotations
  • linting via ruff passed
  • the new files are auto-formatted through black -l 120

Suthep Pomjaksilp added 7 commits June 9, 2026 12:16
Implements the 16-byte FTDI framing protocol via PyD3XX (no vendor
DLLs or Windows-only dependencies). Tested on SLM-200 firmware
2018021001.
Drop-in replacement for Santec. Operates in Memory mode; phase patterns
are double-buffered across slots 1 and 2. Wavelength calibration retries
ReadWL for up to 300 s after WriteWL+WriteAW to handle FPGA settle time.
The warning fires on every import, including from SantecUSB users who
never touch the DLL path. The RuntimeError in Santec.__init__ already
covers missing DLLs with an identical message.
@spomjaksilp spomjaksilp changed the title USB-only driver for Santex SLMs USB-only driver for Santec SLMs Jun 9, 2026
@ichristen

ichristen commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Hi @spomjaksilp ,

We have been excited about your fork! You did great work in reconstructing the Santec API.

Some comments:

  • I would prefer to have Santec and SantecUSB be instead a unified Santec class (which switches between the "dll" and "FTDI"/"USB" cases in the constructor, passed as backend=).
    • Reasoning: there's some repeated code between Santec and SantecUSB and maintaining both could be difficult. A suggested approach is to if/else inside each function to route to each of the two backend cases.
    • We would remove the library loading (and .dll detection in the case of the "dll" backend) from the header, and move it to a function that is called in .info() and in the constructor.
  • Adding memory mode with FTDI is great; we were planning on formalizing support for the memory mode for various SLMs either in 0.5.0 or 0.6.0.
    • Here is what the API will probably look like:
      • set_phase(phase=phase_array, index=None) behaves normally.
      • set_phase(phase=phase_array, index=1) sends the frame to memory slot 1.
      • set_phase(phase=1) sets the SLM to use memory slot 1.
      • ^ All this would be mostly handled by the superclass SLM.
      • Also, we would probably want to zero-index to follow pythonic convention.
      • Currently, Hamamatsu uses slot_number=, but this would be renamed to index=.
      • Subclasses would support _set_phase_hw(display: np.ndarray | int, index: int | None = None).
    • However, the current implementation switching between slots 1 and 2 could be confusing.
      • We could add a flag slm.memory_cycling_buffer : bool to the superclass SLM which, when enabled, activates memory mode and sends frames to slots 1, 2, ... , N, before going back to 1 after N.
      • Why do you not want to use DVI? Are you running the SLM on a small Linux computer (e.g. rasberry pi) which does not have a DVI port? Trying to get a better understanding of your use case.

Let me know what you think! I can also help reorganize the files into a unified class and start putting in the updated memory mode API.

Best,
Ian

@ichristen
ichristen self-requested a review June 9, 2026 15:49

@ichristen ichristen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above. ^

@spomjaksilp

Copy link
Copy Markdown
Author

Hi Ian,

First of all: great work on slmsuite. It's clear the design has been carefully thought through, and the algorithmic foundation is what makes us want to migrate our own SLM/WGS codebase to it.

Santec

Thanks for the suggestion. I agree that reducing duplication is important here. However, I think inheritance is cleaner than if/else backend switching.

Managing all the if/else cases throughout the class gets messy fast. We'd have 4 distinct combinations to support:

DLL + DVI mode
DLL + Memory mode
FTDI + DVI mode
FTDI + Memory mode

With if/else routing, almost every method becomes a branching point. You'd have to touch dozens of places to add a backend or fix a mode-specific issue. It's easy to forget a switch statement and introduce bugs.

Imho I would still go with an inheritance approach but with a common base class encapsulating higher level logic (think of it as "Santec specific routines for the SLM interface":

class SantecBase(SLM):
    # Shared user facing logic: wavelength calibration logic, vendor phase correction, setting phase, necessary abstraction for 
    # has private hooks as abstract methods to be implemented by the children

class SantecDLL(SantecBase):
    def __init__(self, mode='dvi', slm_number=1, display_number=2, ...):

class SantecFTDI(SantecBase):
    def __init__(self, mode='dvi', serial_number, ...):

Each backend subclass only implements what's unique to its communication layer. The mode parameter handles DVI vs Memory differences cleanly without branching.

Users instantiate directly:

slm = SantecDLL(mode='memory', ...)
slm = SantecFTDI(mode='dvi', ...)

We can alias Santec = SantecDLL for backward compatibility if needed.

Memory mode API in SLM

Sounds like nice thing to have in the slm parent class.
However, set_phase(phase: NDArray | int, ...) drastically changes what it does solely inferred from the types of the parameters. This could prove a bit inconsistent for the user since the behavior changes drastically but implicitly.

How about a (imho) cleaner:

upload_slot(phase:  NDArray, index: int = 0, display: bool = True)  # if display -> call display_slot(index)
display_slot(index: int = 0)
# for backward compatibility
set_phase(phase: NDArray | None, index: int | None)  # -> switching depending on mode?

In the Santec case, _set_phase_hw depends heavily on dvi/memory mode (VI=0 vs VI=1). Maybe it makes sense to even provide the abstract hooks _upload_slot_hw and _display_slot_hw in SLM. This way adding memory support for all SLMs might be as easy as implementing these two methods. One could even think about a pseudo memory mode directly in SLM which only caches these phase masks and calls _set_phase_hw(self._phase_chache[index]) once the user calls display_slot[index].

Our Usecase

I would actually like to try to connect the slm to a single board computer of some sorts.
In general, we try to leverage slmsuite with our remote object protocol heros. It provides a nice way to distribute the parts of slmsuite across the network.

E.g. I instantiated a simulated slm and retrieved the transparent proxy object on a different interpreter (could also be a different machine in the net):
remotehero

The whole architecture could then look like:

  • slm connected to an RPi
  • network connected camera
  • algorithm lives on a GPU machine gets the proxy objects remote_slm and remote_camera
  • the user/operator then retrieves the proxy of the algorithm object and runs remote_hologram.optimize(...)

Best,
Suthep

@ichristen

Copy link
Copy Markdown
Collaborator

Hi Suthep @spomjaksilp ,

Algorithms

Ha, the algorithms module is getting a revamp. It's good if you just want to use WGS and get spots, but it's constraining if you want to do something more custom (we intend to wrap the new algorithm structure with backwards-compatible functions that respect the old API, but the number of lines of code inside these wrappers should be minimal).

Santec

Our Meadowlark interface is one example that implements many drivers / modes in a single class. I'm still leaning towards that approach. I can be convinced of the separate class approach. However, I'm mainly concerned about:

  • Duplicated __init__ wavelength table code.
  • Duplicated load_vendor_phase_correction code.
  • Repeated docstrings.

A middle ground approach would be to have hidden driver classes for FTDI and DLL which both implement the same function traces such that these can be interchanged inside a unified Santec class.

Also, regarding FTDI:DVI mode, there would need to be a ScreenMirrored SLM projecting data to the DVI screen (and we would also need to support Santec's 10-bit bitpacking, which is not currently implemented). I'm okay with a pull request that only includes FTDI:memory for now, but there would need to be a plan/place to include the DVI mode as well.

Memory Mode

It depends on your coding philosophy. I prefer to try to minimize the number of functions and number of arguments, overloading and reusing functions in ways that behave "intuitively". If I pass an int to set_phase, the intuitive response is for the SLM to display the contents of a memory slot. Otherwise, there's just too many functions to remember and/or support (there already are too many).

Remote

I'm a little bit concerned about remote holograms. Remote cameras/slms currently kinda work because there's a minimal number of functions that need to be passed, but it's different and more complicated for cameraslms or algorithms. This is why the current slmsuite expectation is for the host computer to be the one with the GPU and handle these more complicated classes. (But I look forward to see how you architect things.)

Best,
Ian

@spomjaksilp

Copy link
Copy Markdown
Author

Thanks for the detailed feedback Ian. Here's what I'll do for the next iteration of this PR:

Class structure

I'll adopt the middle ground you proposed: one public Santec class with hidden driver objects that implement the same function trace.

  • Santec(backend="dll" | "usb", ...) is the only public entry point.
  • The current SantecFTDI protocol layer becomes a private _SantecUSB driver; a thin _SantecDLL driver wraps the vendor DLL with the same trace (open/close, frame write, wavelength table access, status). The unified class holds self._driver and delegates.
  • This puts the wavelength-table initialization, load_vendor_phase_correction(), and the docstrings in exactly one place, while keeping the two transports cleanly separated internally.
  • Following the meadowlark.py conventions: backend-unsupported features raise NotImplementedError with a descriptive message, unknown backends raise RuntimeError.
  • Library loading called lazily from both __init__ and the static info(), analogous to the Meadowlark implementation.

Memory API

I'll defer the public multi-slot API to the superclass work you outlined:

  • Memory mode stays an internal mechanism: double-buffered slot writes behind the standard set_phase(), as before.
  • The hardware hook is shaped to your announced signature already now:
  def _set_phase_hw(self, display, index=None):
      # ndarray, index=None : write to back buffer and swap (default path)
      # ndarray, index=i    : write to slot i
      # int                 : switch the displayed slot

This is backward compatible with the current base class (which calls _set_phase_hw(display)), and Meadowlark already extends the hook with extra kwargs, so there's precedent.

  • Slot handling is zero-indexed throughout.
  • Any remaining slot/trigger extras (cycling, external trigger slot advance) stay private and ready to be wired up once the change to SLM lands.

Best,
Suthep

@ichristen

Copy link
Copy Markdown
Collaborator

Sounds great! Looking forward to it.

@spomjaksilp

spomjaksilp commented Jun 26, 2026

Copy link
Copy Markdown
Author

Hi Ian,

Implemented the refactor as discussed:

  • Santec and SantecUSB are merged into a single Santec(backend="dll"|"usb") class. The two backends are isolated in private adapter classes (_SantecDLLDriver, _SantecUSBDriver) sharing a common interface, so wavelength calibration, error handling, and load_vendor_phase_correction live in one place.
  • Adopted the Meadowlark lazy _load_lib() pattern: importing the package is now fully side-effect-free on all platforms. No DLL or PyD3XX load happens until a Santec instance (or Santec.info()) is called.
  • _set_phase_hw(display, index=None) is wired up as the stable hook. The double-buffer slot management is internal to the USB driver; zero-indexed externally, one-indexed at the FTDI layer.
  • Renamed the device identifier parameter: ftdi_serial for USB (the FTDI chip serial, not the SLM firmware serial from get_firmware_serial()), and slm_number for DLL. The old name device_id was ambiguous since Santec ships all units with FTDI serial "000000000001" by default, which users could easily confuse with the actual SLM serial.
  • I performed a full black -l 120 format against the slms/santec/. This resulted in many changed lines regarding the dll wrapping. They are not functional, just aesthetics regarding black formatting.

Cheers,
Suthep

edit: I tested it on our linux box (smoke test via slm.test()). Testing on the windows box via the dll is incoming :)

@ichristen

Copy link
Copy Markdown
Collaborator

Hi @spomjaksilp

Sorry for my very late reply. Looks good! I made some small commits:

  • Rearranged files to simplify (compacted to remove two files).
  • Edited docstrings to conform with numpydoc.
  • Changed double-buffer to wrap around MAX_SLOTS.

I'm good with approving the pull request pending testing!

Best,
Ian

@ichristen
ichristen self-requested a review August 3, 2026 19:21

@ichristen ichristen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved, pending testing.

@ichristen ichristen added the 0.6.0 Planned part of the 0.6.0 release label Aug 16, 2026
@ichristen

Copy link
Copy Markdown
Collaborator

We should also consider combining with #122

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.6.0 Planned part of the 0.6.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants