diff --git a/.gitignore b/.gitignore index 64b23ca..e6f93af 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ cython_debug/ .DS_Store stellarnet.py stellarnet.hex + +# Coherent HOPS vendor DLLs, capture logs, and local reversing prototypes (not for VCS) +scratch-hops/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c6353..af8892e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ API changes can land even when the minor version is unchanged. ## [Unreleased] +### Added +- `VerdiGDevice` (and `DebugVerdiGDevice`): a laser-source driver for the Coherent + "HOPS" (High Output Power Supply) laser -- Genesis heads / Verdi G-C, e.g. the + lab Genesis CX-Vis (head `G532`). A HOPS supply is not a serial device: its + FTDI FT2232 (`0x0403:0x6010`) is driven as bit-banged I2C, with power DAC, ADC, + shutter/enable GPIO, and the head identity/calibration EEPROM all on one I2C + bus (see `manuals/Coherent-HOPS-*`). `VerdiGDevice` combines `OnOffControl`, + `ShutterControl`, `PowerControl`, and `InterlockControl`, and drives the bus + through an interchangeable `HOPSInterface`: + - `HOPSNativeInterface` (`sources/hopsnative.py`): **pure-Python** pyftdi I2C, + no DLL (macOS/Linux). Hardware-confirmed end to end on the lab unit + (identity, on/off, shutter, remote, power setpoint, temperature). Its + `interlock()`/`faults()` raise `HOPSInterface.NotSupported` until the `?FF` + decode is reverse-engineered. + - `HOPSDLLInterface` (`sources/hopsdll.py`): Coherent's `CohrHOPS.dll` (ASCII + command set; Windows/Linux). Read + `REM`/`PCMD` write paths hardware- + confirmed; `KSWCMD`/`SHCMD` per the DLL spec, not yet exercised. + Selection: `VerdiGDevice(interface="auto")` tries native first, then the DLL; + pass `"native"`/`"dll"`/an interface instance to force one. Protocol and I2C + decode in `manuals/Coherent-HOPS-2-USB-and-DLL-Protocol.md` and + `manuals/Coherent-HOPS-3-I2C-Wire-Protocol.md`. + ## [1.4.0] - 2026-07-08 ### Added diff --git a/examples/verdig/README.md b/examples/verdig/README.md new file mode 100644 index 0000000..badd320 --- /dev/null +++ b/examples/verdig/README.md @@ -0,0 +1,16 @@ +# Verdi-G / HOPS examples + +Read-only monitors for a Coherent HOPS-supply laser (Genesis / Verdi-G). Both +stream once per second and stop on Ctrl-C. Neither takes remote control, so the +front-panel wheel keeps working while they run. + +- `temperature_monitor_native.py` — main temperature over **native pyftdi I2C** + (no DLL). macOS/Linux; needs pyftdi + libusb and the laser on this host. +- `status_monitor_dll.py` — full status (all four servo temperatures, power, + shutter, emission, interlock/faults) over **Coherent's `CohrHOPS.dll`**. + Windows/Linux; needs the DLLs (see `../../hardwarelibrary/manuals/` + `Coherent-HOPS-2-USB-and-DLL-Protocol.md`). + +For the full driver and the two transports, see +`hardwarelibrary/sources/verdig.py` (`VerdiGDevice`) and the `Coherent-HOPS-*` +docs under `hardwarelibrary/manuals/`. diff --git a/examples/verdig/status_monitor_dll.py b/examples/verdig/status_monitor_dll.py new file mode 100644 index 0000000..6496a47 --- /dev/null +++ b/examples/verdig/status_monitor_dll.py @@ -0,0 +1,53 @@ +"""Stream full Verdi-G / HOPS status over Coherent's CohrHOPS.dll (Windows/Linux). + +Read-only: it does not change the laser state or take remote control (the front +panel stays usable). Unlike the native transport, the DLL path also reads all +four thermal-servo temperatures and the interlock/fault state. + +Setup: place CohrHOPS.dll and CohrFTCI2C.dll (matching your Python's bitness) +next to hardwarelibrary/sources/hopsdll.py, or pass dllDirectory=... below. + +Run: python examples/verdig/status_monitor_dll.py [count] +""" +import datetime +import sys +import time + +from hardwarelibrary.sources.hopsdll import HOPSDLLInterface + + +def main(): + count = int(sys.argv[1]) if len(sys.argv) > 1 else None + interface = HOPSDLLInterface() # or HOPSDLLInterface(dllDirectory=r"C:\dlls") + interface.open() + + identity = interface.identity() + print("Connected via CohrHOPS.dll: {0} (head {1}, s/n {2}, max {3:.3f} W)".format( + identity["model"], identity["headType"], identity["serialNumber"], + identity["maxPower"])) + + read = 0 + try: + while count is None or read < count: + diag = interface.diagnostics() # includes the four servo temperatures + temps = ("main={mainTemperature:.1f} SHG={shgTemperature:.1f} " + "BRF={brfTemperature:.1f} etalon={etalonTemperature:.1f}").format(**diag) + faults = interface.faults() + interlock = "ok" if interface.interlockOk() else "FAULT" + stamp = datetime.datetime.now().strftime("%H:%M:%S") + print("{0} {1} C | P={2:.3f} W shutter={3} emission={4} interlock={5}{6}".format( + stamp, temps, interface.getPower(), + "open" if interface.shutterOpen() else "closed", + "ON" if interface.emissionOn() else "off", interlock, + (" faults=" + ",".join(faults)) if faults else "")) + read += 1 + if count is None or read < count: + time.sleep(1.0) + except KeyboardInterrupt: + print() + finally: + interface.close() + + +if __name__ == "__main__": + main() diff --git a/examples/verdig/temperature_monitor_native.py b/examples/verdig/temperature_monitor_native.py new file mode 100644 index 0000000..50b9b81 --- /dev/null +++ b/examples/verdig/temperature_monitor_native.py @@ -0,0 +1,40 @@ +"""Stream the Verdi-G / HOPS main temperature over native pyftdi I2C (no DLL). + +Read-only: it does not change the laser state or take remote control (so the +front panel stays usable). Works on macOS/Linux with the laser attached and +pyftdi + libusb available. + +Run: python examples/verdig/temperature_monitor_native.py [count] +On macOS, put the directory that holds libusb-1.0 on the loader path, e.g.: + DYLD_LIBRARY_PATH=/opt/homebrew/lib python examples/verdig/temperature_monitor_native.py +""" +import datetime +import sys +import time + +from hardwarelibrary.sources.hopsnative import HOPSNativeInterface + + +def main(): + count = int(sys.argv[1]) if len(sys.argv) > 1 else None + interface = HOPSNativeInterface() # or HOPSNativeInterface(url="ftdi://...") + interface.open() + print("Verdi-G / HOPS main-temperature monitor (Ctrl-C to stop). " + "Native calibration valid ~32-40 C.") + read = 0 + try: + while count is None or read < count: + celsius = interface.mainTemperature() + stamp = datetime.datetime.now().strftime("%H:%M:%S") + print("{0} TMAIN = {1:6.2f} C".format(stamp, celsius)) + read += 1 + if count is None or read < count: + time.sleep(1.0) + except KeyboardInterrupt: + print() + finally: + interface.close() + + +if __name__ == "__main__": + main() diff --git a/hardwarelibrary/manuals/Coherent-HOPS-1-Overview-and-Runbook.md b/hardwarelibrary/manuals/Coherent-HOPS-1-Overview-and-Runbook.md new file mode 100644 index 0000000..523d19f --- /dev/null +++ b/hardwarelibrary/manuals/Coherent-HOPS-1-Overview-and-Runbook.md @@ -0,0 +1,290 @@ +# Runbook: controlling the Coherent "Verdi-G" / Genesis HOPS laser + +> HOPS docs, read in order: **1. Overview & Runbook** -> 2. USB/DLL Protocol -> 3. I2C Wire Protocol. + +How we got a lab "Verdi-G" laser under computer control, end to end, and how to +do it again. Written so a future session (human or AI) can reproduce it without +rediscovering the dead ends. + +> **Use at your own risk — reverse-engineered without Coherent.** Everything here +> was obtained by reverse-engineering the Windows-only, proprietary `CohrHOPS.dll`: +> intercepting its calls and dumping the parameters, so a pure-Python macOS/Linux +> version could be written without the DLL. Thanks to +> https://github.com/AllenNeuralDynamics/coherent-lasers for the critical starting +> point. + +**Design.** Everything goes through the HOPS (High Output Power Supply) — which +appears to be a general supply Coherent reuses across many systems — so the driver +is a single `VerdiGDevice` backed by either transport: the DLL (`HOPSDLLInterface`) +or pure-Python pyftdi I2C (`HOPSNativeInterface`). `VerdiGDevice` picks one and is +otherwise unaware of the details. + +For the protocol details see `Coherent-HOPS-2-USB-and-DLL-Protocol.md`; for the driver see +`hardwarelibrary/sources/verdig.py` (`VerdiGDevice`) and its interfaces +`hopsnative.py` / `hopsdll.py`. This file is the *workflow*. + +--- + +## TL;DR + +1. The lab laser labelled "Verdi-G" is actually a **Coherent Genesis CX-Vis** + (head type `G532`, 532 nm) on a **HOPS (High Output Power Supply)**. It is **not a serial + device**: it enumerates as an FTDI FT2232 but is driven as bit-banged I2C, so + nothing answers over a COM port at any baud. +2. The only sane way to talk to it is **Coherent's `CohrHOPS.dll`**, which + exposes an ASCII command API over that binary transport. +3. `CohrHOPS.dll` is **Windows-only**, and the lab control machine is a **Mac**. + The working arrangement: **Claude Code (on macOS) writes the code**, drops it + into a folder that a **Parallels Windows VM** sees, and the DLL is **run in + Windows** (via PowerShell, because the VM had no Python). Output files sync + back to macOS, so Claude reads the results and iterates. +4. The DLL and Coherent's full software are **public** — links below. + +--- + +## What the laser actually is + +Query it and it tells you (this is the real recorded output): + +``` +?LASERMODEL -> Genesis CX-Vis +?HTYPE -> G532 +?HID -> VH5359 (head serial) +CohrHOPS.dll version: 2.0.7 +``` + +USB identity of the supply: FTDI **FT2232**, `VID 0x0403 / PID 0x6010`, +manufacturer `Coherent`, product string `HOPS Power Supply`, adaptor serial +`FTV5L9CA`. On macOS it appears as `/dev/cu.usbserial-FTV5L9CA0` and `...CA1`. + +## Why it can't be driven as a serial port + +The FT2232 enumerates as a virtual COM port, but the HOPS firmware does **not** +run it as a UART. It is driven in bit-banged / MPSSE **I2C** to an NXP micro- +controller in the supply, and the command set is exchanged as **binary frames**. +Consequence, confirmed on the bench with the laser powered and lasing: it returns +**zero bytes** to every serial probe — all bauds 9600–921600, every +databit/parity/stopbit combo, every line terminator — and the FTDI never even +flags a framing error (the RX line never toggles). Do not waste time on pyserial; +it is structurally impossible. + +## The software: CohrHOPS.dll — where to get it + +Coherent's own DLL, headers, demo, and full software package are mirrored +publicly by the Allen Institute's `coherent-lasers` project: + +- **Repo:** https://github.com/AllenNeuralDynamics/coherent-lasers +- **Release with the binaries (v0.1.0):** + https://github.com/AllenNeuralDynamics/coherent-lasers/releases/tag/v0.1.0 + - `CohrHOPS.dll` (64-bit): + https://github.com/AllenNeuralDynamics/coherent-lasers/releases/download/v0.1.0/CohrHOPS.dll + - `CohrFTCI2C.dll` (64-bit, dependency): + https://github.com/AllenNeuralDynamics/coherent-lasers/releases/download/v0.1.0/CohrFTCI2C.dll + - `OPLS.Software.V3.7.1.zip` (359 MB — Coherent's full package, contains the + `CohrHopsDemo` with `main.c`, both 32- and 64-bit DLLs, and `.pdb` debug + symbols): + https://github.com/AllenNeuralDynamics/coherent-lasers/releases/download/v0.1.0/OPLS.Software.V3.7.1.zip +- **Header (API):** + https://raw.githubusercontent.com/AllenNeuralDynamics/coherent-lasers/main/src/coherent_lasers/genesis_mx/hops/CohrHOPS.h +- **Genesis command tables (read/write command strings):** + https://raw.githubusercontent.com/AllenNeuralDynamics/coherent-lasers/main/src/coherent_lasers/genesis_mx/commands.py + +Coherent also distributes the same DLL on the **install CD/USB shipped with the +laser** and, in principle, via Coherent Support (their public Resources page is +JS-gated with no direct download). The GitHub mirror above is the reliable source. + +Copies used in this work are kept locally in the repo at `scratch-hops/` +(`CohrHOPS.dll`, `CohrFTCI2C.dll`, `CohrHOPS.h`, `CohrHopsDemo_main.c`). These are +**vendor binaries — do not commit them** to the repo; treat `scratch-hops/` as a +local stash and gitignore/remove it before a PR. + +Bitness matters: match the DLL to whatever runs it. The standalone release-asset +`CohrHOPS.dll` is **x86-64** (use it with 64-bit PowerShell/Python); the copy +inside `OPLS/CohrHopsDemo/Release` is 32-bit x86. + +--- + +## The workflow that worked (macOS + Parallels Windows) + +### Roles + +- **macOS (Claude Code):** writes all the code and probe scripts; cannot run the + DLL. Places deliverables in a folder the VM can see; reads back the results. +- **Windows VM (Parallels):** runs `CohrHOPS.dll` against the USB-attached laser. +- **Human:** does the few GUI actions the Parallels *Standard* edition blocks + from the command line (start the VM, route the USB device, double-click a file). + +### Why these exact choices + +- **Parallels Standard edition** blocks host→guest automation: `prlctl start` and + `prlctl exec` return *"available only in Parallels Desktop Pro/Business."* So + the Mac side cannot start the VM or run commands inside it. (Pro, ~US $120/yr, + would unlock full automation — not required.) +- **Shared Profile** (Parallels setting, was already on) maps the Mac home into + the guest: the Mac `~/Desktop` appears in Windows as `C:\Mac\Home\Desktop`, and + files the guest writes there sync back to macOS. This is the data bridge. +- **No Python in the guest** — Windows only had the Microsoft Store `python.exe` + *stub* (prints "Python was not found"). So we drive the DLL with **PowerShell + P/Invoke** (`Add-Type` + `DllImport`), which is always present on Windows. + +### Step by step + +1. **macOS (Claude):** build a package folder on the Mac Desktop, e.g. + `~/Desktop/VerdiHOPS/python64/`, containing the 64-bit `CohrHOPS.dll` + + `CohrFTCI2C.dll`, a PowerShell probe (`hops_probe.ps1`), and a launcher + (`RUN_ME.bat`). Because Shared Profile is on, it appears in Windows at + `C:\Mac\Home\Desktop\VerdiHOPS\python64`. +2. **Human:** start the **Windows 11** VM in Parallels. +3. **Human:** route the laser's USB to the guest — Parallels menu bar > + **Devices > USB & Bluetooth > Coherent "HOPS Power Supply"**. When routed it + disappears from macOS (`/dev/cu.usbserial-FTV5L9CA*` gone). Leave the + FieldMaster power meter (also FTDI) on the Mac. +4. **Human:** in Windows, open that folder and **double-click `RUN_ME.bat`**. +5. **macOS (Claude):** read `hops_probe_output.txt` (and `run_log.txt`) from + `~/Desktop/VerdiHOPS/python64/`. Iterate: edit the script on the Mac, have the + human re-run, read again. + +The launcher (`RUN_ME.bat`) runs the probe under PowerShell so no Python is +needed: + +```bat +@echo off +pushd "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "hops_probe.ps1" +popd +echo Press any key to close... +pause >nul +``` + +The probe loads the DLL with P/Invoke, discovers the device, initializes the +handle, sends read-only queries, and writes the results to a file: + +```powershell +Add-Type -TypeDefinition @" +using System; using System.Runtime.InteropServices; using System.Text; +public static class Cohr { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetDllDirectory(string p); + [DllImport("CohrHOPS.dll", CallingConvention=CallingConvention.StdCall)] + public static extern int CohrHOPS_CheckForDevices([Out] ulong[] c, out uint nc, [Out] ulong[] a, out uint na, [Out] ulong[] r, out uint nr); + [DllImport("CohrHOPS.dll", CallingConvention=CallingConvention.StdCall)] + public static extern int CohrHOPS_InitializeHandle(ulong h, StringBuilder head); + [DllImport("CohrHOPS.dll", CallingConvention=CallingConvention.StdCall)] + public static extern int CohrHOPS_SendCommand(ulong h, string cmd, StringBuilder resp); + [DllImport("CohrHOPS.dll", CallingConvention=CallingConvention.StdCall)] + public static extern int CohrHOPS_Close(ulong h); +} +"@ +[Cohr]::SetDllDirectory($PSScriptRoot) | Out-Null +$c=New-Object 'ulong[]' 20; $a=New-Object 'ulong[]' 20; $r=New-Object 'ulong[]' 20 +[uint32]$nc=0;[uint32]$na=0;[uint32]$nr=0 +[void][Cohr]::CohrHOPS_CheckForDevices($c,[ref]$nc,$a,[ref]$na,$r,[ref]$nr) +$h = if ($nc) {$c[0]} else {$a[0]} +$head=New-Object System.Text.StringBuilder 100 +[void][Cohr]::CohrHOPS_InitializeHandle($h,$head) # $head -> "G532" +$resp=New-Object System.Text.StringBuilder 100 +[void][Cohr]::CohrHOPS_SendCommand($h,"?LASERMODEL",$resp) # -> "Genesis CX-Vis" +[void][Cohr]::CohrHOPS_Close($h) +``` + +The full read-only probe (`hops_probe.ps1`) and the safe write-path validation +(`hops_writetest.ps1`, exercises `REM=`/`PCMD=` then restores, never opens the +shutter) live in `~/Desktop/VerdiHOPS/python64/`. + +--- + +## Gotchas we hit (and the fixes) + +- **`.bat` window flashes and closes / does nothing.** From a Parallels shared + folder the script runs from a UNC path (`\\Mac\...`) where `cd /d` silently + fails. Fix: use `pushd "%~dp0"` (maps a temp drive for UNC), log everything to + a file, and end with `pause` so the window never vanishes unseen. +- **"Python not found."** `where python` found only `...WindowsApps\python.exe`, + the Microsoft Store *alias stub*. Fix: don't rely on Python — use PowerShell + P/Invoke (always present). +- **`BadImageFormat` / DLL won't load.** 32/64-bit mismatch, or `CohrFTCI2C.dll` + / `FTD2XX.dll` missing. Fix: use 64-bit DLLs with 64-bit PowerShell; ensure the + FTDI **CDM driver** (provides `FTD2XX.dll`) is installed (Windows usually + auto-installs it when the device is routed). +- **No device found by the DLL.** The USB was not routed to the VM. Fix: route it + in the Parallels Devices > USB menu; confirm it left macOS. +- **The FT2232 is bus-powered**, so its COM ports appear even with the laser + supply switched off — port presence does not mean the laser is on. +- **Don't confuse the ports.** On the lab Mac: `usbmodem48F8834E39561` is a + Millennia eV (STM32 `0x0483:0x5740`, `*IDN?` -> Spectra_Physics), `FTFDLOTS` is + the FieldMaster meter, `FTV5L9CA0/1` is this Genesis/HOPS supply. + +--- + +## The DLL API and command set (essentials) + +`CohrHOPS.dll` exports 6 `extern "C"` functions (ctypes/P-Invoke friendly): + +| Function | Purpose | +|---|---| +| `CohrHOPS_GetDLLVersion(char*)` | version string | +| `CohrHOPS_CheckForDevices(conn,&nc, add,&na, rem,&nr)` | USB discovery -> handles | +| `CohrHOPS_InitializeHandle(handle, char* headOut)` | detect head; returns e.g. `G532` | +| `CohrHOPS_SendCommand(handle, char* cmd, char* respOut)` | one ASCII command -> ASCII reply | +| `CohrHOPS_Close(handle)` | release | +| `CohrHOPS_OpenSerialPort(port, &handle)` | for RS-232 HOPS supplies (not used here) | + +Command vocabulary (Genesis/HOPS): reads `?HID ?HTYPE ?LASERMODEL ?HH ?P ?PCMD +?PLIM ?C ?CLIM ?SH ?KSW ?KSWCMD ?REM ?CMODE ?INT ?FF ?TMAIN ?TSHG ?TBRF ?TETA`; +writes take a `CMD=` suffix: `PCMD=` (power setpoint), `KSWCMD=<0|1>` (software +switch = emission enable), `SHCMD=<0|1>` (shutter), `CMODECMD`, plus `REM=<0|1>` +(remote control — must be `1` before writes take effect). Faults: `?FF` is a hex +bitmask (`0x0020`=interlock, `0x0200`=glue board, etc.). Full table in +`Coherent-HOPS-2-USB-and-DLL-Protocol.md`. + +**Emission model** (no single on/off): needs hardware keyswitch (`?KSW`=1) + +cleared interlock + `REM=1` + `KSWCMD=1` + `PCMD>0`; the mechanical shutter +`SHCMD` gates the beam. + +--- + +## The driver + +`hardwarelibrary/sources/verdig.py` — `VerdiGDevice` (+ `DebugVerdiGDevice`), one +laser-source driver combining `OnOffControl` / `ShutterControl` / `PowerControl` +/ `InterlockControl`. It drives the bus through an interchangeable +`HOPSInterface`, chosen with `VerdiGDevice(interface="auto")` (native first, then +DLL), or forced with `"native"` / `"dll"` / an instance: + +- `hopsnative.py` — `HOPSNativeInterface`: pure-Python pyftdi I2C, no DLL + (macOS/Linux). Hardware-confirmed end to end on the lab unit; `interlock()` / + `faults()` raise `HOPSInterface.NotSupported` until the `?FF` decode is done. +- `hopsdll.py` — `HOPSDLLInterface`: Coherent's `CohrHOPS.dll` (ASCII command + set; Windows/Linux), with the ctypes binding `CohrHOPS`. + +Tests: `hardwarelibrary/tests/testVerdiG.py` (debug + native-on-mock always +run; a hardware class skips when no laser is reachable). + +--- + +## Verified on hardware (2026-07-09, lab Genesis CX-Vis) + +- Read path (identity, power, interlock/faults, all temperatures/hours): OK. +- Write path `REM=1` and `PCMD=0.5` -> read back `?PCMD`=0.498 (head quantizes + to ~2 mW, inside the driver's tolerance), `?P` stayed 0 (no emission, shutter + closed), then restored. So the DLL interface's REM/PCMD/confirm logic is proven. +- **Not yet exercised on hardware (safety):** `KSWCMD` (emission enable) and + `SHCMD` (shutter open). Verify with the beam blocked and interlock cleared. +- Note: at probe time `?FF`=`0x0220` — an active **interlock fault** (+ glue-board + bit); the laser will not emit until that is cleared. + +--- + +## Reproduce from scratch — checklist + +1. Download `CohrHOPS.dll` + `CohrFTCI2C.dll` (64-bit) from the v0.1.0 release + link above. Put them in `~/Desktop/VerdiHOPS/python64/` on the Mac with the + probe scripts from this repo's package. +2. Start the Parallels Windows VM. (Standard edition: do it in the GUI.) +3. Route the Coherent "HOPS Power Supply" USB device to the VM. Ensure the FTDI + CDM driver is installed in Windows. +4. In Windows, open `C:\Mac\Home\Desktop\VerdiHOPS\python64` and double-click + `RUN_ME.bat` (read-only probe) — confirm `?LASERMODEL` / head type. +5. Read `hops_probe_output.txt` back on the Mac. +6. For control, either run PowerShell that calls the DLL (as above) in the guest, + or run `VerdiGDevice(interface="dll")` (this library) inside the guest with a real Python + + the DLLs. For emission, first clear the interlock and block the beam. diff --git a/hardwarelibrary/manuals/Coherent-HOPS-2-USB-and-DLL-Protocol.md b/hardwarelibrary/manuals/Coherent-HOPS-2-USB-and-DLL-Protocol.md new file mode 100644 index 0000000..00db0c8 --- /dev/null +++ b/hardwarelibrary/manuals/Coherent-HOPS-2-USB-and-DLL-Protocol.md @@ -0,0 +1,182 @@ +# Coherent HOPS USB Protocol (Genesis / Verdi G-C) + +> HOPS docs, read in order: 1. Overview & Runbook -> **2. USB/DLL Protocol** -> 3. I2C Wire Protocol. + +How to talk to a Coherent "HOPS" (High Output Power Supply) over USB. This is the transport +used by the Genesis OPSL heads and the **Verdi G- and C-series** -- *not* the +DB-9 ASCII RS-232 protocol of the classic Verdi V-series (a different product +line, not covered here or by this library). + +## Why a HOPS supply looks dead over serial + +A HOPS supply enumerates as an **FTDI FT2232** (`0x0403:0x6010`, USB manufacturer +`Coherent`, product `HOPS Power Supply`), so the OS attaches an FTDI VCP driver +and creates two `/dev/cu.usbserial-*` (or `COMn`) ports. **They answer nothing.** +The FT2232 is not run as a UART: Coherent drives it in **bit-banged / MPSSE I2C** +mode. The supply's **NXP microcontroller** sits on that I2C bus, and the laser +command set is exchanged as **binary frames** on the bus. So no baud rate, parity, +or terminator will ever elicit a reply on the COM port -- verified on the bench: a +powered, lasing Verdi G returned zero bytes across every baud 9600-921600, all +databit/parity/stopbit combinations, and every terminator, with the FTDI never +even flagging a framing error (the RX line never toggles). + +This was established from Coherent's own `CohrHOPS.dll` and its debug symbols +(`.pdb`), which are public in Coherent's OPLS software package (the `CohrHopsDemo` +folder) and mirrored in the AllenNeuralDynamics `coherent-lasers` GitHub release. + +## The stack + +``` +your code + -> CohrHOPS.dll ASCII command API; parses ASCII, builds binary frames + -> CohrFTCI2C.dll hardware-I2C path --. + -> (NXP bit-bang) software-I2C path --+--> FTD2XX.dll -> FT2232 -> I2C bus -> NXP uC -> laser head +``` + +The DLL's `.pdb` reveals an `NXP` class implementing I2C by hand over FTDI GPIO +(`SetClockLineHigh/Low`, `SetDataLineHigh/Low`, `GetDataLine`, `Start`, `Stop`, +`SendByte`, `GetByte`, `MasterAck`, `GetSlaveAck`, `ReadRegister`, +`WriteRegister`), and an `I2C` class for the hardware-assisted FTCI2C path. **Do +not reimplement this blindly**: stray writes on that bus can land on the head's +EEPROM/registers and damage the laser. Use the DLL. + +## The DLL API + +`extern "C"`, `__stdcall`, exported without name mangling -- ctypes-friendly. All +functions return `0` (OK) or a negative status; string buffers are 100 bytes. + +| Function | Purpose | +|---|---| +| `CohrHOPS_GetDLLVersion(char* version)` | DLL version string | +| `CohrHOPS_CheckForDevices(conn,&nConn, added,&nAdd, removed,&nRem)` | USB discovery; returns handle arrays | +| `CohrHOPS_OpenSerialPort(const char* port, HANDLE* handle)` | for RS-232 HOPS supplies | +| `CohrHOPS_InitializeHandle(HANDLE, char* headTypeOut)` | detect head; returns head-type string, or `INVALID_HEAD` | +| `CohrHOPS_SendCommand(HANDLE, char* cmd, char* resp)` | one ASCII command/query -> ASCII response | +| `CohrHOPS_Close(HANDLE)` | release the handle | + +Status codes: `0 OK`, `-1 INVALID_HANDLE`, `-2 INVALID_HEAD`, `-3 INVALID_COMMAND`, +`-4 INVALID_DATA`, `-5 I2C_ERROR`, `-6 USB_ERROR`, `-100..-102 FTCI2C_DLL_*`, +`-200 NXP_ERROR`, `-300 RS232_ERROR`, `-400 THREAD_ERROR`, `-999 OTHER_ERROR`. + +Call order (from Coherent's `CohrHopsDemo/main.c`): +`CheckForDevices` -> `InitializeHandle` -> `SendCommand("?HID")` -> `Close`. + +## Command vocabulary (HOPS/Genesis) + +Queries: `?HTYPE`, `?LASERMODEL`, `?HID`, `?HBDREV`, `?P` (power), `?SH` (shutter), +`?KSW` (keyswitch), `?CMODE`, `?PLIM`, `?CLIM`, `?POWERUNITS`, `?FAN`, `?INT`, +`?ETAD`, `?MAIND`, `?TBRF`, ... Writable settings take a `CMD` suffix: `?PCMD` +(set power), `?SHCMD` (shutter), `?KSWCMD`, `?CMODECMD`, `?TMAINCMD`, etc. This is +distinct from the V-series `?P`/`?L`/`?S`/`P:`/`L:`/`S:` set. + +## Commands implemented by the driver + +These are the ASCII commands `HOPSDLLInterface` actually exchanges through +`CohrHOPS.dll`. The native pyftdi transport (`HOPSNativeInterface`) maps the same +operations onto I2C register reads/writes instead of ASCII (see +`Coherent-HOPS-3-I2C-Wire-Protocol.md`). + +Queries (read) -- prefixed `?`, return an ASCII value: + +| Command | Description | Returns | +|---|---|---| +| `?LASERMODEL` | Laser/head model name | string, e.g. `Genesis CX-Vis` | +| `?HTYPE` | Head type code | string, e.g. `G532` | +| `?HID` | Head serial / ID | string, e.g. `VH5359` | +| `?PLIM` | Maximum (limit) output power | float W, e.g. `7.344` | +| `?P` | Actual output power (post-shutter) | float W, e.g. `0.000` | +| `?PCMD` | Power setpoint (commanded) | float W, e.g. `0.100` | +| `?KSWCMD` | Software switch / emission-enable state | `0` off / `1` on | +| `?SH` | Shutter state | `0` closed / `1` open | +| `?REM` | Remote-control state | `0` off / `1` on | +| `?TMAIN` | Main (baseplate) temperature | float °C | +| `?FF` | Fault bitmask | hex string, e.g. `0220` (see below) | + +Diagnostics queries (read) -- returned by `diagnostics()`: + +| Command | Description | Returns | +|---|---|---| +| `?HH` | Head operating hours | float | +| `?C` | Diode current | float A | +| `?CLIM` | Current limit | float A | +| `?TSHG` | SHG crystal temperature | float °C | +| `?TBRF` | BRF temperature | float °C | +| `?TETA` | Etalon temperature | float °C | +| `?FAN` | Fan speed/state | int | +| `?CMODE` | Control mode | int (`0`/`1`) | + +(`?PLIM` and `?TMAIN` are reused here too.) + +Settings (write) -- `NAME=value`; the response is an ack (empty/echoed) the +driver mostly ignores, except `setPower`, which reads `?PCMD` back to confirm the +value within tolerance: + +| Command | Description | Driver method | +|---|---|---| +| `PCMD=` | Set power setpoint, e.g. `PCMD=0.1000` | `setPower()` | +| `KSWCMD=<0\|1>` | Software switch / emission enable | `turnOn()` / `turnOff()` | +| `SHCMD=<0\|1>` | Shutter (`1` open, `0` close) | `openShutter()` / `closeShutter()` | +| `REM=<0\|1>` | Remote control (`1` on) | set on at init, off at shutdown | + +`?FF` fault bits decoded by `faults()` / `interlockOk()`: + +| Bit | Meaning | +|---|---| +| `0x0008` | Main TEC error | +| `0x0010` | LBO/BRF temperature not OK | +| `0x0020` | Interlock fault | +| `0x0100` | Shutter error | +| `0x0200` | Glue-board error | +| `0x0800` | LDD at current limit | + +`interlockOk()` is true when the interlock bit (`0x0020`) is clear; `faults()` +returns the names of the set bits (e.g. `0x0220` = interlock + glue-board). `?FF`, +`interlockOk()`, and the diagnostics are DLL-only; on the native pyftdi transport +they raise `HOPSInterface.NotSupported` (the `?FF` decode is not yet +reverse-engineered natively). + +## Getting the DLLs + +Not committed here (vendor binaries, Windows-only, bitness-specific). Obtain from: + +- the **install media that shipped with the laser** (the Verdi-G-capable build), or +- Coherent's public **OPLS software** package (`CohrHopsDemo` folder has + `CohrHOPS.dll`, `CohrFTCI2C.dll`, `main.c`, and `.pdb` symbols), or +- the AllenNeuralDynamics **`coherent-lasers`** GitHub release assets. + +Place `CohrHOPS.dll` and `CohrFTCI2C.dll` next to +`hardwarelibrary/sources/verdig.py`, matching your Python's bitness (the OPLS +`CohrHopsDemo/Release` DLL is 32-bit x86; the standalone release-asset build is +x86-64). + +## Using it from this library + +`VerdiGDevice` drives the laser; the DLL is one of its interchangeable transports +(`HOPSDLLInterface`), the native pyftdi I2C path (`HOPSNativeInterface`) the +other. Normally you just let it pick: + +```python +from hardwarelibrary.sources.verdig import VerdiGDevice + +laser = VerdiGDevice(interface="auto") # native first, then the DLL +laser.initializeDevice() +print(laser.laserModel, laser.headType, laser.headSerialNumber) +laser.setPower(0.5); laser.turnOn(); laser.openShutter() +laser.shutdownDevice() +``` + +To force the DLL transport, use `VerdiGDevice(interface="dll")`. The low-level +ctypes binding to `CohrHOPS.dll` is `CohrHOPS` in `sources/hopsdll.py`; on macOS +constructing the DLL interface raises `HOPSInterface.Unavailable` (no DLL for the +platform), so `interface="auto"` falls through to native. + +## Open question for the Verdi G specifically + +The public `CohrHOPS.dll` builds inspected so far list only **Genesis** head types +(`Genesis CX-UV/CX-Vis`, `Genesis MX-MTM/STM`, `Invalid head`) and contain **no +"Verdi" strings**. So `InitializeHandle` may return `INVALID_HEAD` for a Verdi G, +meaning the Verdi-capable CohrHOPS build is the one on the laser's own CD. One run +of the snippet above on a Windows host with the laser attached settles it: if +`initializeHandle` succeeds and `?LASERMODEL` names the Verdi, this DLL works; if +it raises `INVALID_HEAD`, use the laser's shipped DLL instead. Either way the API +and this wrapper are unchanged -- only the DLL file differs. diff --git a/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.png b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.png new file mode 100644 index 0000000..bd24a30 Binary files /dev/null and b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.png differ diff --git a/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.svg b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.svg new file mode 100644 index 0000000..2b0d454 --- /dev/null +++ b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Circuit.svg @@ -0,0 +1,135 @@ + + + + Coherent HOPS / Verdi-G (Genesis CX-Vis, G532) — I2C control bus + Reconstructed from the I2C traffic captured via the CohrFTCI2C.dll logging proxy. Logical bus, not a verified board schematic. + + + + POWER SUPPLY (HOPS) + + LASER HEAD + Genesis CX-Vis / G532 + + + + umbilical / head connector + + + + USB Host + (Mac) + + USB + + + + FTDI FT2232C/D + USB 0x0403:0x6010 + "HOPS Power Supply" + S/N FTV5L9CA + Ch A → MPSSE I2C + Ch B → 2nd port (unused) + force non-3-phase clock (C/D) + + AD0 → SCL + AD1+AD2 → SDA + + + + + + + + + SCL + SDA + + + + +V + + + + + + + + + R₁ pull-ups + + + + + + + + DAC + 0x29 + power setpoint + reg 0xA0, 16-bit + written by PCMD + 0.5 W → 0x0033 + + + + + + + + ADC + 0x48 + power & temperatures + power: reg 0xE4 + TMAIN: reg 0x94 + read by ?P, ?TMAIN + + + + + + + + I/O expanders + 0x20 0x22 0x24 0x25 + (PCA9555-style, x4) + shutter (SHCMD) + keyswitch, remote (REM) + enable (KSWCMD), faults + + + + + + + + EEPROM + 0x52 + reg 0x01XX (2-byte) + head type: G532 + head ID: VH5359 + calibration floats + identity + cal, in head + + + + Confirmed from capture: + 7-bit addresses, registers, and that FT2232 channel A carries the I2C traffic. + Inferred (standard wiring): + SCL=AD0 and SDA=AD1+AD2 (tied), the pull-ups, and the head EEPROM sitting across the umbilical. + Part types: + EEPROM / ADC / DAC / GPIO inferred from register behavior; exact part numbers unknown. + Clock: I2C runs ~10 kHz (CohrHOPS divisor 599). TMAIN sensor is NTC-like (raw falls as temperature rises). + diff --git a/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Wire-Protocol.md b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Wire-Protocol.md new file mode 100644 index 0000000..061f8c9 --- /dev/null +++ b/hardwarelibrary/manuals/Coherent-HOPS-3-I2C-Wire-Protocol.md @@ -0,0 +1,147 @@ +# HOPS I2C protocol — decoded from a live capture + +> HOPS docs, read in order: 1. Overview & Runbook -> 2. USB/DLL Protocol -> **3. I2C Wire Protocol**. + +> **Use at your own risk — reverse-engineered without Coherent.** This was +> obtained by intercepting `CohrHOPS.dll`'s calls and dumping their parameters, to +> write a pure-Python driver without the proprietary, Windows-only DLL. Addresses +> and registers are confirmed from the capture; wiring and part types are inferred. + +Decoded from `ftci2c_capture_readonly.log` (captured 2026-07-09 via the logging +`CohrFTCI2C.dll` proxy, read-only queries only) on the lab Genesis CX-Vis (G532). +This is the wire protocol underneath `CohrHOPS.dll` — what a native `pyftdi` +driver would replay. See `Coherent-HOPS-1-Overview-and-Runbook.md` for how the capture +was made, and `Coherent-HOPS-3-I2C-Circuit.svg` for a bus diagram of the devices +below (logical, reconstructed from this capture — not a verified board schematic). + +## Transport + +FTCI2C setup once at open: `I2C_InitDevice(divisor=599)`, `I2C_SetMode(mode=1)` +(STANDARD_MODE). Every access is a standard I2C "write register pointer, then +read N bytes" (`I2C_Read`, rType=2 = BLOCK_READ). In the FTCI2C control buffer, +byte 0 is the device address with the write bit; the remaining control bytes are +the register pointer written before the repeated-start read. + +The HOPS supply is **not one chip** — it's several I2C devices on one bus: + +| 7-bit addr | Ctrl byte 0 (W) | Role | Register addressing | +|---|---|---|---| +| 0x52 | 0xA4 | **Config/calibration + identity EEPROM** | 2-byte pointer `0x01 0xXX`, 1 byte per read | +| 0x48 | 0x90 | **ADC** (power, temperatures — raw counts) | 1-byte register, reads 2 bytes | +| 0x20 | 0x40 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte | +| 0x22 | 0x44 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte | +| 0x24 | 0x48 | status/GPIO port (keyswitch) | 1-byte register, 1 byte | +| 0x25 | 0x4A | status/GPIO port (shutter) | 1-byte register, 1 byte | + +## EEPROM 0x52 (identity + calibration), page 0x01 + +Strings are read byte-by-byte until a NUL: + +| Reg | Field | Bytes | Value | +|---|---|---|---| +| 0x0100.. | head type (`?HTYPE`) | `47 35 33 32 00` | "G532" | +| 0x0110.. | head ID (`?HID`) | `56 48 35 33 35 39 00` | "VH5359" | +| 0x0160.. | board rev (`?HBDREV`) | `44 45 00` | "DE" | + +Calibration constants are 4-byte **IEEE-754 big-endian** floats: + +| Reg | Bytes | Float | +|---|---|---| +| 0x0120 | `44 95 40 00` | 1194 | +| 0x0124 | `45 CA D0 00` | 6490 | +| 0x0128 | `43 73 00 00` | 243 | +| 0x012C | `46 29 E8 00` | 10874 | +| 0x0180 | `C0 8B 4F 35` | -4.35342 | +| 0x0184 | `3F E7 EF A0` | 1.812 | +| 0x0188 | `C2 30 C3 89` | -44.191 | + +(Blocks repeat at 0x0130/0x0140/0x0150 and 0x0184/0x0188/0x018C — per-channel +gain/offset sets.) **These are the head calibration values.** A stray I2C write +to 0x52 corrupts them — this is the concrete reason not to poke the bus blind. + +## Live signals — ADC 0x48 (raw counts, converted with the cal constants) + +| `?` command | I2C read | raw | note | +|---|---|---|---| +| `?P` (power) | dev 0x48, reg 0xE4, 2 bytes | `00 00` | 0 (laser off); scaled to W via cal | +| `?TMAIN` | dev 0x48, reg 0x94, 2 bytes | `06 A0` | raw ADC; scaled to °C (see below) | + +**TMAIN calibration** (two DLL-correlated points, captured 2026-07-09): +raw 1696 ↔ 32.222 °C and raw 1432 ↔ 39.773 °C — an **inverse** (NTC-like) +relationship. Two-point linear fit: **`T(°C) = -0.028602 · raw + 80.7315`**, +good over ~32–40 °C. (A one-point-through-origin guess is wrong — the slope is +negative. Widen with more points, or use the EEPROM cal floats, for a larger +range.) The **power** ADC read only has the (0 counts → 0 W) point so far; its +non-zero scale needs actual emission to calibrate. + +So `CohrHOPS` reads all EEPROM cal floats at init, then converts ADC counts to +watts/°C. A native driver must reproduce that scaling (or read the cal block and +apply the same gain/offset). + +## Status / faults — GPIO ports 0x20/0x22/0x24/0x25 + +Single-byte port reads whose bits the DLL combines: + +| `?` command | reads | raw | +|---|---|---| +| `?SH` (shutter) | dev 0x25 reg 0x00 | `EC` | +| `?KSW` (keyswitch) | dev 0x24 reg 0x00 | `94` | +| `?FF` (fault) | dev 0x20 reg 0x01/0x00, 0x24 reg 0x01/0x00, 0x22 reg 0x01/0x00 | `DC D5 FA 94 0F 15` | + +The DLL masks specific bits out of these port bytes (e.g. `?SH`->0, `?KSW`->1 on +this run). The exact bit map needs a few more captures with the shutter/keyswitch +toggled to pin down which bit is which. + +## Write protocol (captured 2026-07-09, beam blocked) + +Writes go to two devices that were not touched by reads: + +### Power setpoint — DAC at 0x29 (ctrl byte 0x52), register 0xA0 + +`PCMD=` writes a 16-bit big-endian code to device 0x29, register 0xA0 +(`I2C_Write` wType=2 / PAGE_WRITE, 2 data bytes): + +| `PCMD` | wdata | +|---|---| +| 0.5 W | `00 33` (=51) | +| 0.0 W | `00 00` | + +~102 counts/W through the origin from these two points (single non-zero point, +so the offset/exact scale should be confirmed with a couple more setpoints, or +computed from the EEPROM cal floats). This is **not** the cal EEPROM (0x52) — so +setting power never risks the calibration. + +### Discrete controls — GPIO expander at 0x25 (ctrl byte 0x4A) + +A PCA9555-style 16-bit I/O expander: register **0x02 = output port**, **0x06 = +configuration/direction**. Each command writes 0x06 (make the pin an output) then +0x02 (drive it). The DLL uses a read-modify-write shadow, so a native driver +should read 0x02/0x06, flip only the target bit, and write back (never blast a +whole byte). + +Output-port 0x02 bit map (derived from the deltas): + +| Bit | Mask | Control | Meaning | +|---|---|---|---| +| 0 | 0x01 | shutter (`SHCMD`) | 1 = open, 0 = closed (only `SHCMD=0` captured; `=1` inferred) | +| 3 | 0x08 | remote (`REM`) | **active-low**: 0 = remote on, 1 = off | +| 5 | 0x20 | software switch (`KSWCMD`, emission enable) | 1 = on, 0 = off | + +Captured output-port values: `REM=1`->E4, `KSWCMD=0`->C4, `KSWCMD=1`->E4, +`SHCMD=0`->E4 (bit0 already 0), `REM=0`->EC. + +All writes returned status 0, and the run restored REM/PCMD/KSWCMD and left the +shutter closed (verified in the final-state read). + +## Status of the reverse-engineering + +- **Reads: mapped.** Identity/EEPROM reads are trivially replayable; ADC and GPIO + reads are captured, pending the count->unit scaling and the status bit map. +- **Writes: captured** (see "Write protocol" above). Power = DAC 0x29 reg 0xA0; + shutter/remote/enable = GPIO expander 0x25 bits 0/3/5. Remaining refinement: + a few more `PCMD` points to nail the DAC scale/offset, and confirming `SHCMD=1` + (shutter open) — deliberately not captured to keep the beam blocked. +- A native `pyftdi` driver is therefore feasible: pyftdi's `I2cController` does + exactly "write pointer, read N bytes" on these addresses. The remaining work is + the ADC scaling, the status bit map, and a write capture — not more transport + reverse-engineering. diff --git a/hardwarelibrary/sources/__init__.py b/hardwarelibrary/sources/__init__.py index d6a10c9..e0adb0c 100644 --- a/hardwarelibrary/sources/__init__.py +++ b/hardwarelibrary/sources/__init__.py @@ -10,3 +10,8 @@ MillenniaEv25Device, DebugMillenniaEv25Device, MillenniaDevice, DebugMillenniaDevice, ) +from .verdig import ( + VerdiGDevice, DebugVerdiGDevice, HOPSInterface, DebugHOPSInterface, +) +from .hopsnative import HOPSNativeInterface, HOPSNativeI2C, MockHOPSBus +from .hopsdll import HOPSDLLInterface diff --git a/hardwarelibrary/sources/hopsdll.py b/hardwarelibrary/sources/hopsdll.py new file mode 100644 index 0000000..9655527 --- /dev/null +++ b/hardwarelibrary/sources/hopsdll.py @@ -0,0 +1,244 @@ +"""HOPSDLLInterface: the HOPS transport backed by Coherent's CohrHOPS.dll. + +Implements the transport-agnostic HOPSInterface (see verdig.py) by sending the +DLL's ASCII command set over its binary/I2C transport. Windows/Linux only; +raises HOPSInterface.Unavailable where the DLL cannot load (e.g. macOS). +""" + +import ctypes +import os +import sys +from enum import IntEnum + +from .verdig import HOPSInterface + +MAX_DEVICES = 20 +MAX_STRLEN = 100 +_DLL_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + +class HOPSStatus(IntEnum): + OK = 0 + INVALID_HANDLE = -1 + INVALID_HEAD = -2 + INVALID_COMMAND = -3 + INVALID_DATA = -4 + I2C_ERROR = -5 + USB_ERROR = -6 + FTCI2C_DLL_FILE_NOT_FOUND = -100 + FTCI2C_DLL_FUNCTION_NOT_FOUND = -101 + FTCI2C_DLL_EXCEPTION = -102 + NXP_ERROR = -200 + RS232_ERROR = -300 + THREAD_ERROR = -400 + OTHER_ERROR = -999 + + +class HOPSError(Exception): + def __init__(self, function, status): + self.function = function + self.status = status + try: + name = HOPSStatus(status).name + except ValueError: + name = "UNKNOWN" + super().__init__("{0} failed: {1} ({2})".format(function, status, name)) + + +class CohrHOPS: + """Thin ctypes binding to CohrHOPS.dll (the 6 exported functions).""" + + def __init__(self, dllDirectory=_DLL_DIRECTORY): + if not sys.platform.startswith(("win", "linux")): + raise HOPSInterface.Unavailable( + "CohrHOPS.dll is Windows/Linux only; this is {0}.".format(sys.platform)) + self.dll = self._loadLibrary(dllDirectory) + self._declareSignatures() + + @staticmethod + def _loadLibrary(dllDirectory): + if sys.platform.startswith("win"): + if hasattr(os, "add_dll_directory") and os.path.isdir(dllDirectory): + os.add_dll_directory(dllDirectory) + os.environ["PATH"] = dllDirectory + os.pathsep + os.environ.get("PATH", "") + name = "CohrHOPS.dll" + else: + name = "libcohrhops.so" + path = os.path.join(dllDirectory, name) + if not os.path.exists(path): + raise HOPSInterface.Unavailable( + "{0} not found in {1}. Provide CohrHOPS.dll and CohrFTCI2C.dll " + "(matching your Python's bitness).".format(name, dllDirectory)) + try: + return ctypes.CDLL(path) + except OSError as error: + raise HOPSInterface.Unavailable( + "Could not load {0}: {1} (often a 32/64-bit mismatch or a missing " + "CohrFTCI2C.dll).".format(path, error)) + + def _declareSignatures(self): + handleArray = ctypes.c_uint64 * MAX_DEVICES + self._handleArrayType = handleArray + self.dll.CohrHOPS_GetDLLVersion.argtypes = [ctypes.c_char_p] + self.dll.CohrHOPS_CheckForDevices.argtypes = [ + handleArray, ctypes.POINTER(ctypes.c_uint32), + handleArray, ctypes.POINTER(ctypes.c_uint32), + handleArray, ctypes.POINTER(ctypes.c_uint32)] + self.dll.CohrHOPS_InitializeHandle.argtypes = [ctypes.c_uint64, ctypes.c_char_p] + self.dll.CohrHOPS_SendCommand.argtypes = [ + ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p] + self.dll.CohrHOPS_Close.argtypes = [ctypes.c_uint64] + for fn in ("GetDLLVersion", "CheckForDevices", "InitializeHandle", "SendCommand", "Close"): + getattr(self.dll, "CohrHOPS_" + fn).restype = ctypes.c_int32 + + @staticmethod + def _check(function, status): + if status != HOPSStatus.OK: + raise HOPSError(function, status) + + def version(self) -> str: + buffer = ctypes.create_string_buffer(MAX_STRLEN) + self._check("CohrHOPS_GetDLLVersion", self.dll.CohrHOPS_GetDLLVersion(buffer)) + return buffer.value.decode(errors="replace") + + def checkForDevices(self) -> list: + connected = self._handleArrayType() + added = self._handleArrayType() + removed = self._handleArrayType() + nConnected = ctypes.c_uint32(0) + nAdded = ctypes.c_uint32(0) + nRemoved = ctypes.c_uint32(0) + self._check("CohrHOPS_CheckForDevices", self.dll.CohrHOPS_CheckForDevices( + connected, ctypes.byref(nConnected), added, ctypes.byref(nAdded), + removed, ctypes.byref(nRemoved))) + handles = [connected[i] for i in range(nConnected.value)] + handles += [added[i] for i in range(nAdded.value)] + return sorted(set(h for h in handles if h)) + + def initializeHandle(self, handle: int) -> str: + headType = ctypes.create_string_buffer(MAX_STRLEN) + self._check("CohrHOPS_InitializeHandle", + self.dll.CohrHOPS_InitializeHandle(ctypes.c_uint64(handle), headType)) + return headType.value.decode(errors="replace") + + def sendCommand(self, handle: int, command: str) -> str: + response = ctypes.create_string_buffer(MAX_STRLEN) + self._check("CohrHOPS_SendCommand", self.dll.CohrHOPS_SendCommand( + ctypes.c_uint64(handle), command.encode(), response)) + return response.value.decode(errors="replace").strip() + + def close(self, handle: int): + self._check("CohrHOPS_Close", self.dll.CohrHOPS_Close(ctypes.c_uint64(handle))) + + +class HOPSDLLInterface(HOPSInterface): + """HOPS transport over CohrHOPS.dll (ASCII command set).""" + + name = "dll" + powerSetpointTolerance = 0.02 + + # ?FF fault bitmask; bits 0x0020/0x0100/0x0300 relate to interlocks. + faultBits = { + 0x0008: "Main TEC error", + 0x0010: "LBO/BRF temperature not OK", + 0x0020: "Interlock fault", + 0x0100: "Shutter error", + 0x0200: "Glue board error", + 0x0800: "LDD at current limit", + } + interlockFaultBit = 0x0020 + + diagnosticReads = { + "headHours": ("?HH", float), "current": ("?C", float), + "currentLimit": ("?CLIM", float), "powerLimit": ("?PLIM", float), + "mainTemperature": ("?TMAIN", float), "shgTemperature": ("?TSHG", float), + "brfTemperature": ("?TBRF", float), "etalonTemperature": ("?TETA", float), + "fanSpeed": ("?FAN", int), "mode": ("?CMODE", int), + } + + def __init__(self, serialNumber=None, dllDirectory=_DLL_DIRECTORY): + self.serialNumber = serialNumber + self.dllDirectory = dllDirectory + self.lib = None + self.handle = None + + def open(self): + self.lib = CohrHOPS(self.dllDirectory) # raises HOPSInterface.Unavailable if no DLL + handles = self.lib.checkForDevices() + if not handles: + raise HOPSInterface.Unavailable("CohrHOPS found no HOPS device on USB.") + self.handle = self._selectHandle(handles) + self.lib.initializeHandle(self.handle) + + def _selectHandle(self, handles): + if self.serialNumber is None: + return handles[0] + for handle in handles: + self.lib.initializeHandle(handle) + if self.lib.sendCommand(handle, "?HID") == self.serialNumber: + return handle + raise HOPSInterface.Unavailable("No HOPS head with serial {0}".format(self.serialNumber)) + + def close(self): + if self.lib is not None and self.handle is not None: + self.lib.close(self.handle) + self.lib = None + self.handle = None + + def _query(self, command) -> str: + return self.lib.sendCommand(self.handle, command) + + def identity(self) -> dict: + return { + "model": self._query("?LASERMODEL"), + "headType": self._query("?HTYPE"), + "serialNumber": self._query("?HID"), + "maxPower": float(self._query("?PLIM")), + } + + def getPower(self) -> float: + return float(self._query("?P")) + + def powerSetpoint(self) -> float: + return float(self._query("?PCMD")) + + def setPower(self, power: float): + self.lib.sendCommand(self.handle, "PCMD={0:.4f}".format(power)) + echoed = self.powerSetpoint() + if abs(echoed - power) > self.powerSetpointTolerance: + raise HOPSError("PCMD setpoint confirm", HOPSStatus.INVALID_DATA) + + def emissionOn(self) -> bool: + return self._query("?KSWCMD") == "1" + + def setEmission(self, on: bool): + self.lib.sendCommand(self.handle, "KSWCMD={0}".format(1 if on else 0)) + + def shutterOpen(self) -> bool: + return self._query("?SH") == "1" + + def setShutter(self, isOpen: bool): + self.lib.sendCommand(self.handle, "SHCMD={0}".format(1 if isOpen else 0)) + + def remoteOn(self) -> bool: + return self._query("?REM") == "1" + + def setRemote(self, on: bool): + self.lib.sendCommand(self.handle, "REM={0}".format(1 if on else 0)) + + def mainTemperature(self) -> float: + return float(self._query("?TMAIN")) + + def faults(self) -> list: + reply = self._query("?FF") + code = int(reply, 16) if reply else 0 + return [text for bit, text in self.faultBits.items() if code & bit] + + def interlockOk(self) -> bool: + reply = self._query("?FF") + code = int(reply, 16) if reply else 0 + return (code & self.interlockFaultBit) == 0 + + def diagnostics(self) -> dict: + return {name: cast(self._query(command)) + for name, (command, cast) in self.diagnosticReads.items()} diff --git a/hardwarelibrary/sources/hopsnative.py b/hardwarelibrary/sources/hopsnative.py new file mode 100644 index 0000000..cfa94e5 --- /dev/null +++ b/hardwarelibrary/sources/hopsnative.py @@ -0,0 +1,250 @@ +"""HOPSNativeInterface: the HOPS transport in pure Python over pyftdi I2C. + +Implements the transport-agnostic HOPSInterface (see verdig.py) by driving the +HOPS supply's I2C bus directly through the FT2232 with pyftdi -- no CohrHOPS.dll, +no Windows. Reverse-engineered from sniffed I2C traffic (see +manuals/Coherent-HOPS-3-I2C-Wire-Protocol.md and Coherent-HOPS-3-I2C-Circuit.svg): + + - EEPROM 0x52 (2-byte pointer 0x01XX): identity/calibration (head type 0x00, + head ID 0x10, board rev 0x60). + - ADC 0x48: power at reg 0xE4, main temperature at reg 0x94 (2 bytes, BE). + - I/O expander 0x25 (PCA9555-style): input port 0x00, output 0x02, config 0x06; + bit0 shutter (1=open), bit3 remote (active-low, 0=on), bit5 emission enable. + - DAC 0x29 reg 0xA0: 16-bit power setpoint. + +Gaps (not yet reverse-engineered), so these raise HOPSInterface.NotSupported / +NotCalibrated: the full ?FF fault/interlock decode, and the ADC power-read scale +(only 0 counts = 0 W is known). Main-temperature uses a two-point cal valid +~32-40 C. On Ventura pyftdi claims the FT2232 without unloading Apple's VCP +driver; run with libusb reachable. +""" + +import struct + +from .verdig import HOPSInterface + + +class HOPSNativeI2C: + """Register-level access to the HOPS I2C devices over a pyftdi-style bus. + + The bus exposes get_port(address) with .exchange(out, readlen) (read: write + pointer then read) and .write(out) (write only). HOPSNativeI2C.open() builds + the real pyftdi bus; tests inject a mock with the same interface. + """ + + EEPROM = 0x52 + EEPROM_PAGE = 0x01 + ADC = 0x48 + GPIO = 0x25 + DAC = 0x29 + GPIO_IN = 0x00 + GPIO_OUT = 0x02 + GPIO_CFG = 0x06 + + def __init__(self, bus): + self.bus = bus + + @classmethod + def open(cls, url, frequency=10000): + from pyftdi.ftdi import Ftdi + from pyftdi.i2c import I2cController + try: + Ftdi.add_custom_product(0x0403, 0x6010, "HOPS") + except Exception: + pass + controller = I2cController() + controller.force_clock_mode(True) # FT2232C/D has no 3-phase clock + controller.configure(url, frequency=frequency) + return cls(controller) + + def close(self): + closer = getattr(self.bus, "close", None) + if closer is not None: + closer() + + def readEepromByte(self, register) -> int: + return self.bus.get_port(self.EEPROM).exchange( + [self.EEPROM_PAGE, register & 0xFF], 1)[0] + + def readEepromString(self, base, maxLength=32) -> str: + out = bytearray() + for offset in range(maxLength): + byte = self.readEepromByte(base + offset) + if byte == 0: + break + out.append(byte) + return out.decode("ascii", "replace") + + def readEepromFloat(self, base) -> float: + return struct.unpack(">f", bytes(self.readEepromByte(base + i) for i in range(4)))[0] + + def readAdc(self, register) -> int: + data = self.bus.get_port(self.ADC).exchange([register], 2) + return (data[0] << 8) | data[1] + + def readGpioBit(self, bit) -> int: + port = self.bus.get_port(self.GPIO).exchange([self.GPIO_IN], 1)[0] + return (port >> bit) & 0x01 + + def writeGpioBit(self, bit, value): + # Read-modify-write: make the pin an output (0 in config) and drive it, + # leaving other bits untouched. Writes use write() -- pyftdi rejects + # exchange(out, readlen=0) with "Nothing to read". + gpio = self.bus.get_port(self.GPIO) + config = gpio.exchange([self.GPIO_CFG], 1)[0] & ~(1 << bit) + gpio.write([self.GPIO_CFG, config]) + output = gpio.exchange([self.GPIO_OUT], 1)[0] + output = (output | (1 << bit)) if value else (output & ~(1 << bit)) + gpio.write([self.GPIO_OUT, output]) + + def writeDac(self, register, code): + code &= 0xFFFF + self.bus.get_port(self.DAC).write([register, (code >> 8) & 0xFF, code & 0xFF]) + + +class HOPSNativeInterface(HOPSInterface): + """HOPS transport over native pyftdi I2C (no DLL).""" + + name = "native" + defaultUrl = "ftdi://ftdi:0x6010:FTV5L9CA/1" + + HEAD_TYPE_REG = 0x00 + HEAD_ID_REG = 0x10 + POWER_REG = 0xE4 + TMAIN_REG = 0x94 + SHUTTER_BIT = 0 + REMOTE_BIT = 3 # active-low + ENABLE_BIT = 5 + DAC_POWER_REG = 0xA0 + dacCountsPerWatt = 102.0 # provisional (PCMD=0.5 W -> 0x0033), refine later + + # Main-temperature two-point cal (NTC-like): raw 1696<->32.222, 1432<->39.773. + _a, _b = (1696, 32.222), (1432, 39.773) + tmainSlope = (_b[1] - _a[1]) / (_b[0] - _a[0]) + tmainOffset = _a[1] - tmainSlope * _a[0] + + headCatalog = {"G532": ("Genesis CX-Vis", 7.344)} + + class NotCalibrated(HOPSInterface.NotSupported): + pass + + def __init__(self, url=None, serialNumber=None, bus=None): + self.url = url or self.defaultUrl + self.serialNumber = serialNumber + self._bus = bus # injectable for tests (a MockHOPSBus) + self.i2c = None + self._setpointWatts = 0.0 + self.powerReadCountsPerWatt = None # set after an emission calibration + + def open(self): + try: + self.i2c = HOPSNativeI2C(self._bus) if self._bus is not None else HOPSNativeI2C.open(self.url) + except HOPSInterface.Unavailable: + raise + except Exception as error: + raise HOPSInterface.Unavailable( + "Could not open the HOPS FT2232 over pyftdi/libusb: {0}".format(error)) from error + + def close(self): + if self.i2c is not None: + self.i2c.close() + self.i2c = None + + def identity(self) -> dict: + headType = self.i2c.readEepromString(self.HEAD_TYPE_REG) + model, maxPower = self.headCatalog.get(headType, (headType, None)) + return { + "model": model, + "headType": headType, + "serialNumber": self.i2c.readEepromString(self.HEAD_ID_REG), + "maxPower": maxPower, + } + + def getPower(self) -> float: + raw = self.i2c.readAdc(self.POWER_REG) + if raw == 0: + return 0.0 + if self.powerReadCountsPerWatt is None: + raise HOPSNativeInterface.NotCalibrated( + "power ADC read is uncalibrated (raw={0}); set powerReadCountsPerWatt " + "after calibrating against a meter during emission.".format(raw)) + return raw / self.powerReadCountsPerWatt + + def powerSetpoint(self) -> float: + return self._setpointWatts # DAC is write-only; report last commanded + + def setPower(self, power: float): + self.i2c.writeDac(self.DAC_POWER_REG, int(round(power * self.dacCountsPerWatt))) + self._setpointWatts = power + + def emissionOn(self) -> bool: + return self.i2c.readGpioBit(self.ENABLE_BIT) == 1 + + def setEmission(self, on: bool): + self.i2c.writeGpioBit(self.ENABLE_BIT, 1 if on else 0) + + def shutterOpen(self) -> bool: + return self.i2c.readGpioBit(self.SHUTTER_BIT) == 1 + + def setShutter(self, isOpen: bool): + self.i2c.writeGpioBit(self.SHUTTER_BIT, 1 if isOpen else 0) + + def remoteOn(self) -> bool: + return self.i2c.readGpioBit(self.REMOTE_BIT) == 0 # active-low + + def setRemote(self, on: bool): + self.i2c.writeGpioBit(self.REMOTE_BIT, 0 if on else 1) + + def mainTemperature(self) -> float: + return self.tmainSlope * self.i2c.readAdc(self.TMAIN_REG) + self.tmainOffset + + # interlockOk() and faults() inherit HOPSInterface.NotSupported (the ?FF + # decode was not reverse-engineered natively; to be fixed later). + + def diagnostics(self) -> dict: + return {"mainTemperature": self.mainTemperature()} + + +class MockHOPSBus: + """In-memory pyftdi-style I2C bus for tests. Seeded to the lab G532 snapshot.""" + + def __init__(self): + self.eeprom = {} + self._seed(0x00, "G532") + self._seed(0x10, "VH5359") + self._seed(0x60, "DE") + self.adc = {0xE4: 0x0000, 0x94: 1556} + self.gpio = {0x00: 0x00, 0x02: 0x00, 0x06: 0xFF} + self.dacCode = None + + def _seed(self, base, text): + for offset, char in enumerate(text.encode("ascii")): + self.eeprom[base + offset] = char + self.eeprom[base + len(text)] = 0x00 + + def get_port(self, address): + return MockHOPSBus._Port(self, address) + + class _Port: + def __init__(self, bus, address): + self.bus = bus + self.address = address + + def exchange(self, out, readlen=0): + out = list(out) + if self.address == HOPSNativeI2C.EEPROM: + return bytes([self.bus.eeprom.get(out[1], 0x00)]) + if self.address == HOPSNativeI2C.ADC: + value = self.bus.adc.get(out[0], 0) + return bytes([(value >> 8) & 0xFF, value & 0xFF]) + if self.address == HOPSNativeI2C.GPIO: + source = 0x02 if out[0] == 0x00 else out[0] # input mirrors output + return bytes([self.bus.gpio.get(source, 0x00)]) + return b"" + + def write(self, out): + out = list(out) + if self.address == HOPSNativeI2C.GPIO: + self.bus.gpio[out[0]] = out[1] + elif self.address == HOPSNativeI2C.DAC: + self.bus.dacCode = (out[1] << 8) | out[2] diff --git a/hardwarelibrary/sources/verdig.py b/hardwarelibrary/sources/verdig.py new file mode 100644 index 0000000..d17807e --- /dev/null +++ b/hardwarelibrary/sources/verdig.py @@ -0,0 +1,311 @@ +"""VerdiGDevice: one laser-source driver for a Coherent HOPS supply, over an +interchangeable transport. + +A HOPS ("High Output Power Supply") laser (Genesis heads, Verdi G/C) is not a +serial device: its FT2232 is driven as bit-banged I2C, and all control -- power +DAC, ADC, shutter/enable GPIO, and the head's identity/calibration EEPROM across +the umbilical -- hangs off that one I2C bus (see manuals/Coherent-HOPS-*). So a +driver must speak that bus, either through Coherent's CohrHOPS.dll or natively. + +VerdiGDevice holds one HOPSInterface and delegates every capability hook to it: + + - HOPSNativeInterface (hopsnative.py): pure-Python pyftdi I2C, no DLL (macOS/Linux) + - HOPSDLLInterface (hopsdll.py): Coherent's CohrHOPS.dll (Windows/Linux) + +Selection: VerdiGDevice(interface="auto") tries native first, then the DLL; pass +"native"/"dll" to force one, or an interface instance directly. The debug device +uses an in-memory DebugHOPSInterface and needs neither hardware nor pyftdi/DLL. + +The lab unit reports as a Genesis CX-Vis (head G532). InterlockControl is part of +the contract, but the native interface cannot decode faults yet, so on the native +transport interlock()/faults() raise HOPSInterface.NotSupported (to be fixed once +the ?FF decode is reverse-engineered). +""" + +from abc import ABC, abstractmethod + +from ..physicaldevice import PhysicalDevice +from .lasersourcedevice import LaserSourceDevice +from .capabilities import OnOffControl, ShutterControl, PowerControl, InterlockControl + + +class HOPSInterface(ABC): + """Transport-agnostic contract VerdiGDevice drives. Implemented by + HOPSDLLInterface and HOPSNativeInterface.""" + + name = "interface" + + class Unavailable(Exception): + """This transport cannot be opened on this host (no DLL, no pyftdi, no + device). Used by VerdiGDevice's auto-selection to fall through.""" + + class NotSupported(Exception): + """This transport cannot perform the requested operation (e.g. native + interlock/fault decode).""" + + @abstractmethod + def open(self): ... + + @abstractmethod + def close(self): ... + + @abstractmethod + def identity(self) -> dict: + """{model, headType, serialNumber, maxPower (or None)}.""" + + @abstractmethod + def getPower(self) -> float: ... + + @abstractmethod + def powerSetpoint(self) -> float: ... + + @abstractmethod + def setPower(self, power: float): ... + + @abstractmethod + def emissionOn(self) -> bool: ... + + @abstractmethod + def setEmission(self, on: bool): ... + + @abstractmethod + def shutterOpen(self) -> bool: ... + + @abstractmethod + def setShutter(self, isOpen: bool): ... + + @abstractmethod + def remoteOn(self) -> bool: ... + + @abstractmethod + def setRemote(self, on: bool): ... + + @abstractmethod + def mainTemperature(self) -> float: ... + + def interlockOk(self) -> bool: + raise HOPSInterface.NotSupported( + "interlock state is not available on the {0} interface".format(self.name)) + + def faults(self) -> list: + raise HOPSInterface.NotSupported( + "fault decode is not available on the {0} interface".format(self.name)) + + def diagnostics(self) -> dict: + return {} + + +class VerdiGDevice(LaserSourceDevice, OnOffControl, ShutterControl, PowerControl, + InterlockControl): + """Coherent Verdi-G laser on a HOPS supply (the lab head reports as a Genesis + CX-Vis, G532), driven through an interchangeable HOPSInterface -- native + pyftdi I2C or CohrHOPS.dll. See the module docstring.""" + + classIdVendor = 0x0403 # FTDI FT2232 the HOPS supply enumerates as + classIdProduct = 0x6010 + + def __init__(self, interface="auto", url=None, serialNumber=None, + idProduct=None, idVendor=None): + super().__init__(serialNumber=serialNumber, idProduct=idProduct, idVendor=idVendor) + self._interfaceChoice = interface + self.url = url + self.interface = None + self.laserModel = None + self.headType = None + self.headSerialNumber = None + self.maxPower = None + + def _interfaceCandidates(self): + choice = self._interfaceChoice + if isinstance(choice, HOPSInterface): + return [lambda: choice] + serial = None if self.serialNumber in (None, ".*") else self.serialNumber + + def native(): + from .hopsnative import HOPSNativeInterface + return HOPSNativeInterface(url=self.url, serialNumber=serial) + + def dll(): + from .hopsdll import HOPSDLLInterface + return HOPSDLLInterface(serialNumber=serial) + + table = {"auto": [native, dll], "native": [native], "dll": [dll]} + if choice not in table: + raise PhysicalDevice.UnableToInitialize( + "interface must be 'auto', 'native', 'dll', or a HOPSInterface") + return table[choice] + + def doInitializeDevice(self): + errors = [] + for makeInterface in self._interfaceCandidates(): + interface = makeInterface() + try: + interface.open() + except HOPSInterface.Unavailable as error: + errors.append("{0}: {1}".format(interface.name, error)) + continue + self.interface = interface + break + if self.interface is None: + raise PhysicalDevice.UnableToInitialize( + "No HOPS interface could be opened. " + " | ".join(errors)) + try: + identity = self.interface.identity() + self.laserModel = identity.get("model") + self.headType = identity.get("headType") + self.headSerialNumber = identity.get("serialNumber") + self.maxPower = identity.get("maxPower") + self.interface.setRemote(True) + except Exception as error: + self.interface.close() + self.interface = None + raise PhysicalDevice.UnableToInitialize(error) from error + + def doShutdownDevice(self): + if self.interface is not None: + try: + self.interface.setRemote(False) + finally: + self.interface.close() + self.interface = None + + # OnOffControl (emission enable) + def doTurnOn(self): + self.interface.setEmission(True) + + def doTurnOff(self): + self.interface.setEmission(False) + + def doGetOnOffState(self) -> bool: + return self.interface.emissionOn() + + # ShutterControl + def doOpenShutter(self): + self.interface.setShutter(True) + + def doCloseShutter(self): + self.interface.setShutter(False) + + def doGetShutterState(self) -> bool: + return self.interface.shutterOpen() + + # PowerControl + def doSetPower(self, power: float): + upper = self.maxPower if self.maxPower is not None else float("inf") + if not (0.0 <= power <= upper): + raise ValueError("power {0} W outside [0, {1}] W".format(power, self.maxPower)) + self.interface.setPower(power) + + def doGetPower(self) -> float: + return self.interface.getPower() + + # InterlockControl (native raises HOPSInterface.NotSupported, by design) + def doGetInterlockState(self) -> bool: + return self.interface.interlockOk() + + # Extra, non-capability helpers delegated to the interface + def powerSetpoint(self) -> float: + return self.interface.powerSetpoint() + + def mainTemperature(self) -> float: + return self.interface.mainTemperature() + + def faults(self) -> list: + return self.interface.faults() + + def diagnostics(self) -> dict: + return self.interface.diagnostics() + + def remoteControlIsOn(self) -> bool: + return self.interface.remoteOn() + + def doGetStatusUserInfo(self) -> dict: + info = { + "power": self.doGetPower(), + "setpoint": self.powerSetpoint(), + "isLaserOn": self.doGetOnOffState(), + "isShutterOpen": self.doGetShutterState(), + "remoteControl": self.remoteControlIsOn(), + "mainTemperature": self.mainTemperature(), + } + try: + info["interlockOk"] = self.doGetInterlockState() + info["faults"] = self.faults() + except HOPSInterface.NotSupported: + info["interlockOk"] = None + info["faults"] = None + return info + + +class DebugHOPSInterface(HOPSInterface): + """In-memory HOPS transport for tests -- needs neither pyftdi nor the DLL. + Fully capable (implements interlock/faults) so VerdiGDevice's whole contract can + be exercised. Seeded to the lab Genesis CX-Vis (G532).""" + + name = "debug" + + def __init__(self): + self.emission = False + self.shutter = False + self.remote = False + self.setpoint = 0.0 + self.tmain = 36.0 + self.activeFaults = [] + self.opened = False + + def open(self): + self.opened = True + + def close(self): + self.opened = False + + def identity(self) -> dict: + return {"model": "Genesis CX-Vis", "headType": "G532", + "serialNumber": "VH5359", "maxPower": 7.344} + + def getPower(self) -> float: + return self.setpoint if (self.emission and self.shutter) else 0.0 + + def powerSetpoint(self) -> float: + return self.setpoint + + def setPower(self, power: float): + self.setpoint = power + + def emissionOn(self) -> bool: + return self.emission + + def setEmission(self, on: bool): + self.emission = bool(on) + + def shutterOpen(self) -> bool: + return self.shutter + + def setShutter(self, isOpen: bool): + self.shutter = bool(isOpen) + + def remoteOn(self) -> bool: + return self.remote + + def setRemote(self, on: bool): + self.remote = bool(on) + + def mainTemperature(self) -> float: + return self.tmain + + def interlockOk(self) -> bool: + return not any(f in self.activeFaults for f in ("Interlock fault",)) + + def faults(self) -> list: + return list(self.activeFaults) + + def diagnostics(self) -> dict: + return {"mainTemperature": self.tmain} + + +class DebugVerdiGDevice(VerdiGDevice): + classIdVendor = 0xFFFF + classIdProduct = 0xFFF4 + + def __init__(self, serialNumber="debug"): + super().__init__(interface=DebugHOPSInterface(), serialNumber=serialNumber) diff --git a/hardwarelibrary/tests/testVerdiG.py b/hardwarelibrary/tests/testVerdiG.py new file mode 100644 index 0000000..c570b87 --- /dev/null +++ b/hardwarelibrary/tests/testVerdiG.py @@ -0,0 +1,179 @@ +import env +import unittest + +from hardwarelibrary.physicaldevice import PhysicalDevice, DeviceState +from hardwarelibrary.sources.verdig import ( + VerdiGDevice, DebugVerdiGDevice, HOPSInterface, DebugHOPSInterface) +from hardwarelibrary.sources.hopsnative import ( + HOPSNativeInterface, HOPSNativeI2C, MockHOPSBus) +from hardwarelibrary.sources.capabilities import ( + OnOffControl, ShutterControl, PowerControl, InterlockControl, WavelengthControl) +from hardwarelibrary.sources.lasersourcedevice import LaserSourceDevice + +# Point at a real Verdi-G / HOPS laser on this host to exercise the hardware class. +NATIVE_URL = "ftdi://ftdi:0x6010:FTV5L9CA/1" + + +class TestVerdiGWithDebugInterface(unittest.TestCase): + def setUp(self): + self.laser = DebugVerdiGDevice() + self.laser.initializeDevice() + + def tearDown(self): + if self.laser.state == DeviceState.Ready: + self.laser.shutdownDevice() + + def testAdvertisesFullCapabilitySet(self): + self.assertIsInstance(self.laser, LaserSourceDevice) + self.assertEqual(set(self.laser.capabilities()), + {OnOffControl, ShutterControl, PowerControl, InterlockControl}) + self.assertFalse(self.laser.hasCapability(WavelengthControl)) + + def testReadsIdentity(self): + self.assertEqual(self.laser.laserModel, "Genesis CX-Vis") + self.assertEqual(self.laser.headType, "G532") + self.assertEqual(self.laser.headSerialNumber, "VH5359") + self.assertAlmostEqual(self.laser.maxPower, 7.344, places=3) + + def testInitEnablesRemoteShutdownDisablesIt(self): + self.assertTrue(self.laser.remoteControlIsOn()) + interface = self.laser.interface + self.laser.shutdownDevice() + self.assertFalse(interface.remoteOn()) + + def testTurnOnOff(self): + self.laser.turnOn() + self.assertTrue(self.laser.isLaserOn()) + self.laser.turnOff() + self.assertFalse(self.laser.isLaserOn()) + + def testShutterRoundTrip(self): + self.laser.openShutter() + self.assertTrue(self.laser.isShutterOpen()) + self.laser.closeShutter() + self.assertFalse(self.laser.isShutterOpen()) + + def testSetPowerAndSetpoint(self): + self.laser.setPower(3.0) + self.assertAlmostEqual(self.laser.powerSetpoint(), 3.0, places=3) + + def testActualPowerNeedsEnableAndShutter(self): + self.laser.setPower(2.0) + self.assertEqual(self.laser.power(), 0.0) + self.laser.turnOn() + self.laser.openShutter() + self.assertAlmostEqual(self.laser.power(), 2.0, places=3) + + def testRejectsOutOfRangePower(self): + with self.assertRaises(ValueError): + self.laser.setPower(self.laser.maxPower + 1.0) + with self.assertRaises(ValueError): + self.laser.setPower(-0.1) + + def testInterlockAndFaults(self): + self.assertTrue(self.laser.interlock()) + self.assertEqual(self.laser.faults(), []) + self.laser.interface.activeFaults = ["Interlock fault"] + self.assertFalse(self.laser.interlock()) + self.assertIn("Interlock fault", self.laser.faults()) + + def testStatusUserInfo(self): + self.laser.turnOn(); self.laser.openShutter(); self.laser.setPower(1.5) + info = self.laser.doGetStatusUserInfo() + self.assertAlmostEqual(info["power"], 1.5, places=3) + self.assertTrue(info["isLaserOn"]) + self.assertTrue(info["isShutterOpen"]) + self.assertTrue(info["remoteControl"]) + self.assertTrue(info["interlockOk"]) + + +class TestInterfaceSelection(unittest.TestCase): + def testPassingAnInterfaceInstanceUsesIt(self): + interface = DebugHOPSInterface() + laser = VerdiGDevice(interface=interface) + laser.initializeDevice() + self.assertIs(laser.interface, interface) + laser.shutdownDevice() + + def testUnknownInterfaceChoiceRaises(self): + laser = VerdiGDevice(interface="banana") + with self.assertRaises(PhysicalDevice.UnableToInitialize): + laser.initializeDevice() + + +class TestNativeInterfaceOnMock(unittest.TestCase): + def setUp(self): + self.interface = HOPSNativeInterface(bus=MockHOPSBus()) + self.laser = VerdiGDevice(interface=self.interface) + self.laser.initializeDevice() + + def tearDown(self): + if self.laser.state == DeviceState.Ready: + self.laser.shutdownDevice() + + def testNativeReadsIdentity(self): + self.assertEqual(self.laser.headType, "G532") + self.assertEqual(self.laser.laserModel, "Genesis CX-Vis") + + def testNativeShutterAndEnableViaBits(self): + self.laser.openShutter() + self.assertTrue(self.laser.isShutterOpen()) + self.laser.turnOn() + self.assertTrue(self.laser.isLaserOn()) + + def testNativeSetPowerWritesDac(self): + self.laser.setPower(3.0) + self.assertEqual(self.interface._bus.dacCode, + int(round(3.0 * self.interface.dacCountsPerWatt))) + + def testNativeTemperatureCalibration(self): + self.interface._bus.adc[0x94] = 1696 + self.assertAlmostEqual(self.laser.mainTemperature(), 32.222, places=2) + + def testNativeInterlockRaisesNotSupported(self): + with self.assertRaises(HOPSInterface.NotSupported): + self.laser.interlock() + with self.assertRaises(HOPSInterface.NotSupported): + self.laser.faults() + + def testNativePowerUncalibratedNonzeroRaises(self): + self.interface._bus.adc[0xE4] = 1234 + with self.assertRaises(HOPSInterface.NotSupported): # NotCalibrated subclass + self.laser.power() + + +class TestHOPSNativeI2CReadModifyWrite(unittest.TestCase): + def testWriteGpioBitPreservesOtherBits(self): + bus = MockHOPSBus() + i2c = HOPSNativeI2C(bus) + bus.gpio[0x02] = 0b00000101 + i2c.writeGpioBit(5, 1) + self.assertEqual(bus.gpio[0x02], 0b00100101) + i2c.writeGpioBit(0, 0) + self.assertEqual(bus.gpio[0x02], 0b00100100) + + +class TestVerdiGHardware(unittest.TestCase): + def setUp(self): + self.laser = VerdiGDevice(interface="native", url=NATIVE_URL) + try: + self.laser.initializeDevice() + except PhysicalDevice.UnableToInitialize: + self.skipTest("No native Verdi-G / HOPS laser reachable via pyftdi on this host") + + def tearDown(self): + if self.laser.state == DeviceState.Ready: + self.laser.shutdownDevice() + + def testReadsIdentity(self): + self.assertIsNotNone(self.laser.headType) + + def testReadsTemperature(self): + self.assertIsInstance(self.laser.mainTemperature(), float) + + def testReadsShutterState(self): + self.assertIn(self.laser.isShutterOpen(), (True, False)) + + +if __name__ == "__main__": + unittest.main()