Skip to content

Commit eb0ae74

Browse files
dccoteclaude
andcommitted
Add Coherent HOPS / Verdi-G (Genesis) laser driver
Problem: The lab needed computer control of its Coherent green laser. It presents as a "Verdi-G" but is actually a Genesis CX-Vis (head G532) on a HOPS power supply whose USB is not a serial port: the FT2232 is driven as bit-banged I2C, so no serial framing ever answers. Solution: - hops.py (HOPSInterface): a ctypes binding to Coherent's CohrHOPS.dll, the supported way to talk to a HOPS supply (ASCII command API over the binary/I2C transport). Windows/Linux only; raises on macOS. - VerdiGDevice (+ DebugVerdiGDevice) in sources/verdig.py: OnOff (software switch KSWCMD), Power (PCMD/?P), Shutter (SHCMD/?SH), Interlock (?FF fault bitmask), plus a diagnostics() table. On macOS doInitializeDevice raises UnableToInitialize (DLL cannot load); DebugVerdiGDevice runs anywhere. - manuals/: a HOPS USB-protocol reference; a reproducible runbook for the macOS-writes / Windows-runs / Parallels workflow used to reach the laser; 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 cal); and an SVG bus diagram. Verified on the lab Genesis CX-Vis: the read path and the REM/PCMD write path are hardware-confirmed; the emission-enable (KSWCMD) and shutter (SHCMD) writes are per the DLL spec, not yet exercised. Tests: 21 pass, 4 skip (hardware absent). The untested Verdi V-series (V-2/V-5/V-6) RS-232 driver was intentionally left out: no such hardware is available to validate it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01841beMNdCw3zYf6fdBUoy7
1 parent 5efa9a0 commit eb0ae74

10 files changed

Lines changed: 1331 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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ 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+
927
## [1.4.0] - 2026-07-08
1028

1129
### Added
Lines changed: 135 additions & 0 deletions
Loading
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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, and `Coherent-HOPS-I2C-Circuit.svg` for a bus diagram of the devices
8+
below (logical, reconstructed from this capture — not a verified board schematic).
9+
10+
## Transport
11+
12+
FTCI2C setup once at open: `I2C_InitDevice(divisor=599)`, `I2C_SetMode(mode=1)`
13+
(STANDARD_MODE). Every access is a standard I2C "write register pointer, then
14+
read N bytes" (`I2C_Read`, rType=2 = BLOCK_READ). In the FTCI2C control buffer,
15+
byte 0 is the device address with the write bit; the remaining control bytes are
16+
the register pointer written before the repeated-start read.
17+
18+
The HOPS supply is **not one chip** — it's several I2C devices on one bus:
19+
20+
| 7-bit addr | Ctrl byte 0 (W) | Role | Register addressing |
21+
|---|---|---|---|
22+
| 0x52 | 0xA4 | **Config/calibration + identity EEPROM** | 2-byte pointer `0x01 0xXX`, 1 byte per read |
23+
| 0x48 | 0x90 | **ADC** (power, temperatures — raw counts) | 1-byte register, reads 2 bytes |
24+
| 0x20 | 0x40 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte |
25+
| 0x22 | 0x44 | status/GPIO port | 1-byte register (0x00/0x01), 1 byte |
26+
| 0x24 | 0x48 | status/GPIO port (keyswitch) | 1-byte register, 1 byte |
27+
| 0x25 | 0x4A | status/GPIO port (shutter) | 1-byte register, 1 byte |
28+
29+
## EEPROM 0x52 (identity + calibration), page 0x01
30+
31+
Strings are read byte-by-byte until a NUL:
32+
33+
| Reg | Field | Bytes | Value |
34+
|---|---|---|---|
35+
| 0x0100.. | head type (`?HTYPE`) | `47 35 33 32 00` | "G532" |
36+
| 0x0110.. | head ID (`?HID`) | `56 48 35 33 35 39 00` | "VH5359" |
37+
| 0x0160.. | board rev (`?HBDREV`) | `44 45 00` | "DE" |
38+
39+
Calibration constants are 4-byte **IEEE-754 big-endian** floats:
40+
41+
| Reg | Bytes | Float |
42+
|---|---|---|
43+
| 0x0120 | `44 95 40 00` | 1194 |
44+
| 0x0124 | `45 CA D0 00` | 6490 |
45+
| 0x0128 | `43 73 00 00` | 243 |
46+
| 0x012C | `46 29 E8 00` | 10874 |
47+
| 0x0180 | `C0 8B 4F 35` | -4.35342 |
48+
| 0x0184 | `3F E7 EF A0` | 1.812 |
49+
| 0x0188 | `C2 30 C3 89` | -44.191 |
50+
51+
(Blocks repeat at 0x0130/0x0140/0x0150 and 0x0184/0x0188/0x018C — per-channel
52+
gain/offset sets.) **These are the head calibration values.** A stray I2C write
53+
to 0x52 corrupts them — this is the concrete reason not to poke the bus blind.
54+
55+
## Live signals — ADC 0x48 (raw counts, converted with the cal constants)
56+
57+
| `?` command | I2C read | raw | note |
58+
|---|---|---|---|
59+
| `?P` (power) | dev 0x48, reg 0xE4, 2 bytes | `00 00` | 0 (laser off); scaled to W via cal |
60+
| `?TMAIN` | dev 0x48, reg 0x94, 2 bytes | `06 A0` | raw ADC; scaled to °C (see below) |
61+
62+
**TMAIN calibration** (two DLL-correlated points, captured 2026-07-09):
63+
raw 1696 ↔ 32.222 °C and raw 1432 ↔ 39.773 °C — an **inverse** (NTC-like)
64+
relationship. Two-point linear fit: **`T(°C) = -0.028602 · raw + 80.7315`**,
65+
good over ~32–40 °C. (A one-point-through-origin guess is wrong — the slope is
66+
negative. Widen with more points, or use the EEPROM cal floats, for a larger
67+
range.) The **power** ADC read only has the (0 counts → 0 W) point so far; its
68+
non-zero scale needs actual emission to calibrate.
69+
70+
So `CohrHOPS` reads all EEPROM cal floats at init, then converts ADC counts to
71+
watts/°C. A native driver must reproduce that scaling (or read the cal block and
72+
apply the same gain/offset).
73+
74+
## Status / faults — GPIO ports 0x20/0x22/0x24/0x25
75+
76+
Single-byte port reads whose bits the DLL combines:
77+
78+
| `?` command | reads | raw |
79+
|---|---|---|
80+
| `?SH` (shutter) | dev 0x25 reg 0x00 | `EC` |
81+
| `?KSW` (keyswitch) | dev 0x24 reg 0x00 | `94` |
82+
| `?FF` (fault) | dev 0x20 reg 0x01/0x00, 0x24 reg 0x01/0x00, 0x22 reg 0x01/0x00 | `DC D5 FA 94 0F 15` |
83+
84+
The DLL masks specific bits out of these port bytes (e.g. `?SH`->0, `?KSW`->1 on
85+
this run). The exact bit map needs a few more captures with the shutter/keyswitch
86+
toggled to pin down which bit is which.
87+
88+
## Write protocol (captured 2026-07-09, beam blocked)
89+
90+
Writes go to two devices that were not touched by reads:
91+
92+
### Power setpoint — DAC at 0x29 (ctrl byte 0x52), register 0xA0
93+
94+
`PCMD=<w>` writes a 16-bit big-endian code to device 0x29, register 0xA0
95+
(`I2C_Write` wType=2 / PAGE_WRITE, 2 data bytes):
96+
97+
| `PCMD` | wdata |
98+
|---|---|
99+
| 0.5 W | `00 33` (=51) |
100+
| 0.0 W | `00 00` |
101+
102+
~102 counts/W through the origin from these two points (single non-zero point,
103+
so the offset/exact scale should be confirmed with a couple more setpoints, or
104+
computed from the EEPROM cal floats). This is **not** the cal EEPROM (0x52) — so
105+
setting power never risks the calibration.
106+
107+
### Discrete controls — GPIO expander at 0x25 (ctrl byte 0x4A)
108+
109+
A PCA9555-style 16-bit I/O expander: register **0x02 = output port**, **0x06 =
110+
configuration/direction**. Each command writes 0x06 (make the pin an output) then
111+
0x02 (drive it). The DLL uses a read-modify-write shadow, so a native driver
112+
should read 0x02/0x06, flip only the target bit, and write back (never blast a
113+
whole byte).
114+
115+
Output-port 0x02 bit map (derived from the deltas):
116+
117+
| Bit | Mask | Control | Meaning |
118+
|---|---|---|---|
119+
| 0 | 0x01 | shutter (`SHCMD`) | 1 = open, 0 = closed (only `SHCMD=0` captured; `=1` inferred) |
120+
| 3 | 0x08 | remote (`REM`) | **active-low**: 0 = remote on, 1 = off |
121+
| 5 | 0x20 | software switch (`KSWCMD`, emission enable) | 1 = on, 0 = off |
122+
123+
Captured output-port values: `REM=1`->E4, `KSWCMD=0`->C4, `KSWCMD=1`->E4,
124+
`SHCMD=0`->E4 (bit0 already 0), `REM=0`->EC.
125+
126+
All writes returned status 0, and the run restored REM/PCMD/KSWCMD and left the
127+
shutter closed (verified in the final-state read).
128+
129+
## Status of the reverse-engineering
130+
131+
- **Reads: mapped.** Identity/EEPROM reads are trivially replayable; ADC and GPIO
132+
reads are captured, pending the count->unit scaling and the status bit map.
133+
- **Writes: captured** (see "Write protocol" above). Power = DAC 0x29 reg 0xA0;
134+
shutter/remote/enable = GPIO expander 0x25 bits 0/3/5. Remaining refinement:
135+
a few more `PCMD` points to nail the DAC scale/offset, and confirming `SHCMD=1`
136+
(shutter open) — deliberately not captured to keep the beam blocked.
137+
- A native `pyftdi` driver is therefore feasible: pyftdi's `I2cController` does
138+
exactly "write pointer, read N bytes" on these addresses. The remaining work is
139+
the ADC scaling, the status bit map, and a write capture — not more transport
140+
reverse-engineering.

0 commit comments

Comments
 (0)