Skip to content

Commit 27a1a20

Browse files
dccoteclaude
andcommitted
Add Coherent Verdi (V-series) and HOPS/Verdi-G (Genesis) laser drivers
Problem: The lab needed computer control of a Coherent green laser. Two distinct devices turned out to be involved, neither previously supported: a classic Verdi V-series (RS-232 ASCII) and, on the bench, a "Verdi-G" that is actually a Genesis CX-Vis on a HOPS power supply whose USB is not a serial port at all. Solution: - VerdiDevice (+ VerdiV2/V5/V6Device, DebugVerdiDevice) in sources/verdi.py: the V-2/V-5/V-6 RS-232 ASCII driver (OnOff/Shutter/Power/Interlock). Built from the operator's manual; the PROMPT-polarity and CRLF-terminator details were corrected against two working third-party drivers. Verified against the mock only (no V-series hardware on hand). - hops.py (HOPSInterface): a ctypes binding to Coherent's CohrHOPS.dll, the only supported way to talk to a HOPS supply (the FT2232 is driven as bit-banged I2C, not a UART, so no serial framing ever answers). - VerdiGDevice (+ DebugVerdiGDevice) in sources/verdig.py: OnOff/Power/Shutter/ Interlock plus a diagnostics() table, over hops.py. Hardware-confirmed on the lab Genesis CX-Vis (G532) for the read path and the REM/PCMD write path; the emission-enable and shutter writes are per the DLL spec, not yet exercised. - manuals/: the V-series operator's manual (PDF) and serial-command reference; a HOPS USB-protocol reference; a reproducible runbook for the macOS-writes / Windows-runs / Parallels workflow used to reach the laser; and a decode of the HOPS I2C protocol sniffed via a logging CohrFTCI2C.dll proxy (identity/cal EEPROM 0x52, ADC 0x48, GPIO 0x25, power DAC 0x29, TMAIN calibration). VerdiGDevice runs only where CohrHOPS.dll loads (Windows/Linux); on macOS it raises UnableToInitialize and DebugVerdiGDevice is used. A native pyftdi path on macOS is proven for reads (identity/power/temperature) in local prototypes kept in the gitignored scratch-hops/ (with the vendor DLLs). Tests: 44 pass, 11 skip (hardware absent) across testVerdi.py and testVerdiG.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01841beMNdCw3zYf6fdBUoy7
1 parent 5efa9a0 commit 27a1a20

13 files changed

Lines changed: 1954 additions & 0 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,6 @@ cython_debug/
141141
.DS_Store
142142
stellarnet.py
143143
stellarnet.hex
144+
145+
# Coherent HOPS vendor DLLs, capture logs, and local reversing prototypes (not for VCS)
146+
scratch-hops/

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,45 @@ API changes can land even when the minor version is unchanged.
66

77
## [Unreleased]
88

9+
### Added
10+
- `VerdiGDevice` (and `DebugVerdiGDevice`), plus `hops.py` (`HOPSInterface`): a
11+
driver for Coherent OPSL lasers on a "HOPS" power supply, controlled over USB
12+
through Coherent's `CohrHOPS.dll` rather than a serial protocol. The supply
13+
enumerates as an FTDI FT2232 (`0x0403:0x6010`) but is driven as bit-banged I2C
14+
to an NXP micro-controller, so the DLL (ASCII command API over that binary
15+
transport) is the only supported path; `hops.py` binds it with ctypes.
16+
`VerdiGDevice` combines `OnOffControl` (software switch `KSWCMD`),
17+
`PowerControl` (`PCMD`/`?P`), `ShutterControl` (`SHCMD`/`?SH`), and
18+
`InterlockControl` (`?FF` fault bitmask). Verified on the lab unit (which
19+
reports as a Genesis CX-Vis, head `G532`): the read path and the remote/power
20+
setpoint write path (`REM=`, `PCMD=`) are hardware-confirmed; the emission
21+
enable (`KSWCMD`) and shutter (`SHCMD`) are per the DLL spec but not yet
22+
exercised on hardware. The DLL is Windows/Linux-only,
23+
so on macOS `doInitializeDevice` raises `UnableToInitialize`; use
24+
`DebugVerdiGDevice`. Protocol reference in
25+
`manuals/Coherent-HOPS-USB-Protocol.md`.
26+
- `VerdiDevice` (and per-model `VerdiV2Device` / `VerdiV5Device` / `VerdiV6Device`,
27+
plus `DebugVerdiDevice`): Coherent Verdi V-2/V-5/V-6 diode-pumped CW DPSS laser
28+
(532 nm) over RS-232 (through a USB-serial adapter). Combines `OnOffControl`
29+
(`L=1`/`L=0`/`?L`, gated by the front keyswitch), `ShutterControl`
30+
(`S=1`/`S=0`/`?S`), `PowerControl` (`P=`/`?P` actual, `?SP` setpoint), and
31+
`InterlockControl` (derived from the `?F` fault query, since the Verdi has no
32+
dedicated interlock query). Instantiated by `portPath` like `CoboltDevice`
33+
(the laser has no USB identity of its own); 19200 8-N-1 by default;
34+
`doInitializeDevice` forces echo/prompt terminal modes to a known state and
35+
reads the power-supply software version. Unlike the Millennia eV, the Verdi
36+
answers every instruction, so each write consumes its reply and a `RANGE ERROR`
37+
/ `Command Error` / `Query Error` reply raises `VerdiDevice.CommandRejected`.
38+
Serial-command reference in `manuals/Coherent-Verdi-Serial-Commands.md`.
39+
40+
**Scope and verification status:** this driver covers the **V-series only**. The
41+
Verdi **G-series and C-series are not supported**: their "HOPS" power supply has
42+
no DB-9 and exposes only USB via an FTDI FT2232 (`0x0403:0x6010`, manufacturer
43+
`Coherent`) running a proprietary protocol -- a G-series unit ignored the entire
44+
ASCII command set at every baud, framing, and terminator while lasing. The driver
45+
is verified only against `DebugVerdiDevice`; **no command has been confirmed
46+
against real V-series hardware.**
47+
948
## [1.4.0] - 2026-07-08
1049

1150
### Added
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# HOPS I2C protocol — decoded from a live capture
2+
3+
Decoded from `ftci2c_capture_readonly.log` (captured 2026-07-09 via the logging
4+
`CohrFTCI2C.dll` proxy, read-only queries only) on the lab Genesis CX-Vis (G532).
5+
This is the wire protocol underneath `CohrHOPS.dll` — what a native `pyftdi`
6+
driver would replay. See `Coherent-HOPS-Verdi-G-Runbook.md` for how the capture
7+
was made.
8+
9+
## Transport
10+
11+
FTCI2C setup once at open: `I2C_InitDevice(divisor=599)`, `I2C_SetMode(mode=1)`
12+
(STANDARD_MODE). Every access is a standard I2C "write register pointer, then
13+
read N bytes" (`I2C_Read`, rType=2 = BLOCK_READ). In the FTCI2C control buffer,
14+
byte 0 is the device address with the write bit; the remaining control bytes are
15+
the register pointer written before the repeated-start read.
16+
17+
The HOPS supply is **not one chip** — it's several I2C devices on one bus:
18+
19+
| 7-bit addr | Ctrl byte 0 (W) | Role | Register addressing |
20+
|---|---|---|---|
21+
| 0x52 | 0xA4 | **Config/calibration + identity EEPROM** | 2-byte pointer `0x01 0xXX`, 1 byte per read |
22+
| 0x48 | 0x90 | **ADC** (power, temperatures — raw counts) | 1-byte register, reads 2 bytes |
23+
| 0x20 | 0x40 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte |
24+
| 0x22 | 0x44 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte |
25+
| 0x24 | 0x48 | status/GPIO port (keyswitch) | 1-byte register, 1 byte |
26+
| 0x25 | 0x4A | status/GPIO port (shutter) | 1-byte register, 1 byte |
27+
28+
## EEPROM 0x52 (identity + calibration), page 0x01
29+
30+
Strings are read byte-by-byte until a NUL:
31+
32+
| Reg | Field | Bytes | Value |
33+
|---|---|---|---|
34+
| 0x0100.. | head type (`?HTYPE`) | `47 35 33 32 00` | "G532" |
35+
| 0x0110.. | head ID (`?HID`) | `56 48 35 33 35 39 00` | "VH5359" |
36+
| 0x0160.. | board rev (`?HBDREV`) | `44 45 00` | "DE" |
37+
38+
Calibration constants are 4-byte **IEEE-754 big-endian** floats:
39+
40+
| Reg | Bytes | Float |
41+
|---|---|---|
42+
| 0x0120 | `44 95 40 00` | 1194 |
43+
| 0x0124 | `45 CA D0 00` | 6490 |
44+
| 0x0128 | `43 73 00 00` | 243 |
45+
| 0x012C | `46 29 E8 00` | 10874 |
46+
| 0x0180 | `C0 8B 4F 35` | -4.35342 |
47+
| 0x0184 | `3F E7 EF A0` | 1.812 |
48+
| 0x0188 | `C2 30 C3 89` | -44.191 |
49+
50+
(Blocks repeat at 0x0130/0x0140/0x0150 and 0x0184/0x0188/0x018C — per-channel
51+
gain/offset sets.) **These are the head calibration values.** A stray I2C write
52+
to 0x52 corrupts them — this is the concrete reason not to poke the bus blind.
53+
54+
## Live signals — ADC 0x48 (raw counts, converted with the cal constants)
55+
56+
| `?` command | I2C read | raw | note |
57+
|---|---|---|---|
58+
| `?P` (power) | dev 0x48, reg 0xE4, 2 bytes | `00 00` | 0 (laser off); scaled to W via cal |
59+
| `?TMAIN` | dev 0x48, reg 0x94, 2 bytes | `06 A0` | raw ADC; scaled to °C (see below) |
60+
61+
**TMAIN calibration** (two DLL-correlated points, captured 2026-07-09):
62+
raw 1696 ↔ 32.222 °C and raw 1432 ↔ 39.773 °C — an **inverse** (NTC-like)
63+
relationship. Two-point linear fit: **`T(°C) = -0.028602 · raw + 80.7315`**,
64+
good over ~32–40 °C. (A one-point-through-origin guess is wrong — the slope is
65+
negative. Widen with more points, or use the EEPROM cal floats, for a larger
66+
range.) The **power** ADC read only has the (0 counts → 0 W) point so far; its
67+
non-zero scale needs actual emission to calibrate.
68+
69+
So `CohrHOPS` reads all EEPROM cal floats at init, then converts ADC counts to
70+
watts/°C. A native driver must reproduce that scaling (or read the cal block and
71+
apply the same gain/offset).
72+
73+
## Status / faults — GPIO ports 0x20/0x22/0x24/0x25
74+
75+
Single-byte port reads whose bits the DLL combines:
76+
77+
| `?` command | reads | raw |
78+
|---|---|---|
79+
| `?SH` (shutter) | dev 0x25 reg 0x00 | `EC` |
80+
| `?KSW` (keyswitch) | dev 0x24 reg 0x00 | `94` |
81+
| `?FF` (fault) | dev 0x20 reg 0x01/0x00, 0x24 reg 0x01/0x00, 0x22 reg 0x01/0x00 | `DC D5 FA 94 0F 15` |
82+
83+
The DLL masks specific bits out of these port bytes (e.g. `?SH`->0, `?KSW`->1 on
84+
this run). The exact bit map needs a few more captures with the shutter/keyswitch
85+
toggled to pin down which bit is which.
86+
87+
## Write protocol (captured 2026-07-09, beam blocked)
88+
89+
Writes go to two devices that were not touched by reads:
90+
91+
### Power setpoint — DAC at 0x29 (ctrl byte 0x52), register 0xA0
92+
93+
`PCMD=<w>` writes a 16-bit big-endian code to device 0x29, register 0xA0
94+
(`I2C_Write` wType=2 / PAGE_WRITE, 2 data bytes):
95+
96+
| `PCMD` | wdata |
97+
|---|---|
98+
| 0.5 W | `00 33` (=51) |
99+
| 0.0 W | `00 00` |
100+
101+
~102 counts/W through the origin from these two points (single non-zero point,
102+
so the offset/exact scale should be confirmed with a couple more setpoints, or
103+
computed from the EEPROM cal floats). This is **not** the cal EEPROM (0x52) — so
104+
setting power never risks the calibration.
105+
106+
### Discrete controls — GPIO expander at 0x25 (ctrl byte 0x4A)
107+
108+
A PCA9555-style 16-bit I/O expander: register **0x02 = output port**, **0x06 =
109+
configuration/direction**. Each command writes 0x06 (make the pin an output) then
110+
0x02 (drive it). The DLL uses a read-modify-write shadow, so a native driver
111+
should read 0x02/0x06, flip only the target bit, and write back (never blast a
112+
whole byte).
113+
114+
Output-port 0x02 bit map (derived from the deltas):
115+
116+
| Bit | Mask | Control | Meaning |
117+
|---|---|---|---|
118+
| 0 | 0x01 | shutter (`SHCMD`) | 1 = open, 0 = closed (only `SHCMD=0` captured; `=1` inferred) |
119+
| 3 | 0x08 | remote (`REM`) | **active-low**: 0 = remote on, 1 = off |
120+
| 5 | 0x20 | software switch (`KSWCMD`, emission enable) | 1 = on, 0 = off |
121+
122+
Captured output-port values: `REM=1`->E4, `KSWCMD=0`->C4, `KSWCMD=1`->E4,
123+
`SHCMD=0`->E4 (bit0 already 0), `REM=0`->EC.
124+
125+
All writes returned status 0, and the run restored REM/PCMD/KSWCMD and left the
126+
shutter closed (verified in the final-state read).
127+
128+
## Status of the reverse-engineering
129+
130+
- **Reads: mapped.** Identity/EEPROM reads are trivially replayable; ADC and GPIO
131+
reads are captured, pending the count->unit scaling and the status bit map.
132+
- **Writes: captured** (see "Write protocol" above). Power = DAC 0x29 reg 0xA0;
133+
shutter/remote/enable = GPIO expander 0x25 bits 0/3/5. Remaining refinement:
134+
a few more `PCMD` points to nail the DAC scale/offset, and confirming `SHCMD=1`
135+
(shutter open) — deliberately not captured to keep the beam blocked.
136+
- A native `pyftdi` driver is therefore feasible: pyftdi's `I2cController` does
137+
exactly "write pointer, read N bytes" on these addresses. The remaining work is
138+
the ADC scaling, the status bit map, and a write capture — not more transport
139+
reverse-engineering.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Coherent HOPS USB Protocol (Genesis / Verdi G-C)
2+
3+
How to talk to a Coherent "HOPS" power supply over USB. This is the transport
4+
used by the Genesis OPSL heads and the **Verdi G- and C-series** -- *not* the
5+
DB-9 ASCII RS-232 protocol of the classic Verdi V-series (that is
6+
`Coherent-Verdi-Serial-Commands.md`, and it does not apply here).
7+
8+
## Why a HOPS supply looks dead over serial
9+
10+
A HOPS supply enumerates as an **FTDI FT2232** (`0x0403:0x6010`, USB manufacturer
11+
`Coherent`, product `HOPS Power Supply`), so the OS attaches an FTDI VCP driver
12+
and creates two `/dev/cu.usbserial-*` (or `COMn`) ports. **They answer nothing.**
13+
The FT2232 is not run as a UART: Coherent drives it in **bit-banged / MPSSE I2C**
14+
mode. The supply's **NXP microcontroller** sits on that I2C bus, and the laser
15+
command set is exchanged as **binary frames** on the bus. So no baud rate, parity,
16+
or terminator will ever elicit a reply on the COM port -- verified on the bench: a
17+
powered, lasing Verdi G returned zero bytes across every baud 9600-921600, all
18+
databit/parity/stopbit combinations, and every terminator, with the FTDI never
19+
even flagging a framing error (the RX line never toggles).
20+
21+
This was established from Coherent's own `CohrHOPS.dll` and its debug symbols
22+
(`.pdb`), which are public in Coherent's OPLS software package (the `CohrHopsDemo`
23+
folder) and mirrored in the AllenNeuralDynamics `coherent-lasers` GitHub release.
24+
25+
## The stack
26+
27+
```
28+
your code
29+
-> CohrHOPS.dll ASCII command API; parses ASCII, builds binary frames
30+
-> CohrFTCI2C.dll hardware-I2C path --.
31+
-> (NXP bit-bang) software-I2C path --+--> FTD2XX.dll -> FT2232 -> I2C bus -> NXP uC -> laser head
32+
```
33+
34+
The DLL's `.pdb` reveals an `NXP` class implementing I2C by hand over FTDI GPIO
35+
(`SetClockLineHigh/Low`, `SetDataLineHigh/Low`, `GetDataLine`, `Start`, `Stop`,
36+
`SendByte`, `GetByte`, `MasterAck`, `GetSlaveAck`, `ReadRegister`,
37+
`WriteRegister`), and an `I2C` class for the hardware-assisted FTCI2C path. **Do
38+
not reimplement this blindly**: stray writes on that bus can land on the head's
39+
EEPROM/registers and damage the laser. Use the DLL.
40+
41+
## The DLL API
42+
43+
`extern "C"`, `__stdcall`, exported without name mangling -- ctypes-friendly. All
44+
functions return `0` (OK) or a negative status; string buffers are 100 bytes.
45+
46+
| Function | Purpose |
47+
|---|---|
48+
| `CohrHOPS_GetDLLVersion(char* version)` | DLL version string |
49+
| `CohrHOPS_CheckForDevices(conn,&nConn, added,&nAdd, removed,&nRem)` | USB discovery; returns handle arrays |
50+
| `CohrHOPS_OpenSerialPort(const char* port, HANDLE* handle)` | for RS-232 HOPS supplies |
51+
| `CohrHOPS_InitializeHandle(HANDLE, char* headTypeOut)` | detect head; returns head-type string, or `INVALID_HEAD` |
52+
| `CohrHOPS_SendCommand(HANDLE, char* cmd, char* resp)` | one ASCII command/query -> ASCII response |
53+
| `CohrHOPS_Close(HANDLE)` | release the handle |
54+
55+
Status codes: `0 OK`, `-1 INVALID_HANDLE`, `-2 INVALID_HEAD`, `-3 INVALID_COMMAND`,
56+
`-4 INVALID_DATA`, `-5 I2C_ERROR`, `-6 USB_ERROR`, `-100..-102 FTCI2C_DLL_*`,
57+
`-200 NXP_ERROR`, `-300 RS232_ERROR`, `-400 THREAD_ERROR`, `-999 OTHER_ERROR`.
58+
59+
Call order (from Coherent's `CohrHopsDemo/main.c`):
60+
`CheckForDevices` -> `InitializeHandle` -> `SendCommand("?HID")` -> `Close`.
61+
62+
## Command vocabulary (HOPS/Genesis)
63+
64+
Queries: `?HTYPE`, `?LASERMODEL`, `?HID`, `?HBDREV`, `?P` (power), `?SH` (shutter),
65+
`?KSW` (keyswitch), `?CMODE`, `?PLIM`, `?CLIM`, `?POWERUNITS`, `?FAN`, `?INT`,
66+
`?ETAD`, `?MAIND`, `?TBRF`, ... Writable settings take a `CMD` suffix: `?PCMD`
67+
(set power), `?SHCMD` (shutter), `?KSWCMD`, `?CMODECMD`, `?TMAINCMD`, etc. This is
68+
distinct from the V-series `?P`/`?L`/`?S`/`P:`/`L:`/`S:` set.
69+
70+
## Getting the DLLs
71+
72+
Not committed here (vendor binaries, Windows-only, bitness-specific). Obtain from:
73+
74+
- the **install media that shipped with the laser** (the Verdi-G-capable build), or
75+
- Coherent's public **OPLS software** package (`CohrHopsDemo` folder has
76+
`CohrHOPS.dll`, `CohrFTCI2C.dll`, `main.c`, and `.pdb` symbols), or
77+
- the AllenNeuralDynamics **`coherent-lasers`** GitHub release assets.
78+
79+
Place `CohrHOPS.dll` and `CohrFTCI2C.dll` next to
80+
`hardwarelibrary/sources/hops.py`, matching your Python's bitness (the OPLS
81+
`CohrHopsDemo/Release` DLL is 32-bit x86; the standalone release-asset build is
82+
x86-64).
83+
84+
## Using it from this library
85+
86+
`hardwarelibrary/sources/hops.py` binds the DLL with ctypes:
87+
88+
```python
89+
from hardwarelibrary.sources.hops import HOPSInterface
90+
91+
hops = HOPSInterface() # loads CohrHOPS.dll (Windows/Linux)
92+
print("DLL", hops.version())
93+
handles = hops.checkForDevices() # USB discovery
94+
head = hops.initializeHandle(handles[0]) # -> head-type string, or HOPSError(INVALID_HEAD)
95+
print("head:", head)
96+
print(hops.sendCommand(handles[0], "?HTYPE"))
97+
print(hops.sendCommand(handles[0], "?LASERMODEL"))
98+
hops.close(handles[0])
99+
```
100+
101+
On macOS this raises `HOPSUnavailable` (no DLL exists for the platform).
102+
103+
## Open question for the Verdi G specifically
104+
105+
The public `CohrHOPS.dll` builds inspected so far list only **Genesis** head types
106+
(`Genesis CX-UV/CX-Vis`, `Genesis MX-MTM/STM`, `Invalid head`) and contain **no
107+
"Verdi" strings**. So `InitializeHandle` may return `INVALID_HEAD` for a Verdi G,
108+
meaning the Verdi-capable CohrHOPS build is the one on the laser's own CD. One run
109+
of the snippet above on a Windows host with the laser attached settles it: if
110+
`initializeHandle` succeeds and `?LASERMODEL` names the Verdi, this DLL works; if
111+
it raises `INVALID_HEAD`, use the laser's shipped DLL instead. Either way the API
112+
and this wrapper are unchanged -- only the DLL file differs.

0 commit comments

Comments
 (0)