diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index d54d41c..345cd11 100755 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -130,15 +130,21 @@ class HeliosLaser: return lines time.sleep(0.005) - @staticmethod - def _value_in(line: str, mnemonic: str) -> Optional[str]: + # The only replies that come back without naming what they answer. + # Every other line has to identify itself: an unlabelled number is not + # evidence that it is *this* register's number, and taking one on faith + # is how a status register's value ends up displayed as a diode current. + UNLABELLED_REPLIES = frozenset({"CSR", "HSR"}) + + @classmethod + def _value_in(cls, line: str, mnemonic: str) -> Optional[str]: """The value `line` holds for `mnemonic`, or None if it isn't its reply. The controller answers "LDF = 20000 ns". A line naming a different mnemonic is the tail of an earlier reply, and "Bit 15..0: ..." is a status register's decode line; neither is an answer to this query. - A line naming nothing is taken as the value — that is how the serial - numbers come back. + A line naming nothing counts only for the serial numbers, which is + the one reply known to come back bare. """ head, sep, tail = line.partition('=') if sep: @@ -152,7 +158,9 @@ class HeliosLaser: fields = line.split() if fields and fields[0].upper() == mnemonic: return fields[1] if len(fields) > 1 else None - return line + if mnemonic in cls.UNLABELLED_REPLIES: + return line + return None def _query(self, command: str) -> Optional[str]: """Send a query and return the value from its response. diff --git a/hardware/helios_registers.py b/hardware/helios_registers.py new file mode 100644 index 0000000..1d1ee9a --- /dev/null +++ b/hardware/helios_registers.py @@ -0,0 +1,102 @@ +"""Helios status-register bit definitions (Tables 8-1, 8-2, 8-3). + +Kept out of the driver and out of the Qt apps so that a command-line +diagnostic can decode a register without importing either. + +Each entry: bit_number -> (severity, description, comment) + severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none +""" + +LER_FLAGS = { + 0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)', + 'Check CCE register for details'), + 1: ('S', 'Trigger input active', + 'High when trigger signal applied or laser in continuous pulsing'), + 2: ('I', 'Command error', + 'Unknown command sent to controller'), + 3: ('C', 'Laser disable pin open (utility connector)', + 'Shuts down pump diodes; reset LER 0 required to restart'), + 4: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 5: ('C', 'Over voltage laser diode', + 'Check for open circuit or voltage spikes'), + 6: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 7: ('C', 'Controller temperature failure at pump diodes', + 'Check LCE register for details'), + 8: ('S', 'Laser start delay (60 s warmup)', + 'Laser cannot be started yet; status error LED flashing'), + 9: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 10: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 11: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 12: ('S', 'Slave controller error (remote input)', + 'Valid only for master controller coupled with a slave'), + 13: ('', 'Laserhead not found', + 'Head not connected / not found; check EMI; ignore for double-electronic slave'), + 14: ('', 'Laserhead I\u00b2C acknowledge error', + 'Check environment for strong EMI; ignore for double-electronic slave'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +LCE_FLAGS = { + 0: ('C', 'Pump diode over/under temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'), + 1: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 2: ('C', 'Pump diode temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 3: ('C', 'Pump diode current critical', + 'Current set too close to current limit'), + 4: ('C', 'Pump diode temperature out of limit', + 'Pump diode temperature is out of limit'), + 5: ('S', 'Door switch open', + 'Close utility connector pin 2 permanently to pin 9 (GND)'), + 7: ('C', 'Pump diode NTC error', + 'Invalid temperature measured or NTC broken'), + 8: ('C', 'Laser diode power stage over temperature', + 'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'), + 9: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 10: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 11: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +CCE_FLAGS = { + 0: ('C', 'Resonator/SHG under/over temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), + 1: ('C', 'Resonator/SHG NTC failure', + 'Temperature sensor broken or disconnected'), + 2: ('C', 'Resonator/SHG temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 3: ('C', 'Q-switch ADC / temperature readout failure', + 'Internal hardware error or no NTC connected'), + 4: ('C', 'Q-switch temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 5: ('C', 'Q-switch under/over temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), + 7: ('C', 'Q-switch NTC failure', + 'Internal hardware error or no NTC connected'), + 8: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'} + + +def decode_register(flags_dict: dict, value: int) -> list: + """Return list of (bit, severity, description, comment) for each set bit.""" + active = [] + for bit, (sev, desc, comment) in flags_dict.items(): + if value & (1 << bit): + active.append((bit, sev, desc, comment)) + return active diff --git a/helios_test_app.py b/helios_test_app.py index 0d4d307..5d4fa76 100755 --- a/helios_test_app.py +++ b/helios_test_app.py @@ -16,106 +16,9 @@ from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject from PyQt6.QtGui import QFont from hardware.helios_laser import HeliosLaser, PulseMode - -# --------------------------------------------------------------------------- -# Status register bit definitions (Tables 8-1, 8-2, 8-3 — Helios manual) -# Each entry: bit_number -> (severity, description, comment) -# severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none -# --------------------------------------------------------------------------- -_LER_FLAGS = { - 0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)', - 'Check CCE register for details'), - 1: ('S', 'Trigger input active', - 'High when trigger signal applied or laser in continuous pulsing'), - 2: ('I', 'Command error', - 'Unknown command sent to controller'), - 3: ('C', 'Laser disable pin open (utility connector)', - 'Shuts down pump diodes; reset LER 0 required to restart'), - 4: ('C', 'Internal hardware failure', - 'Contact Coherent'), - 5: ('C', 'Over voltage laser diode', - 'Check for open circuit or voltage spikes'), - 6: ('C', 'Internal hardware failure', - 'Contact Coherent'), - 7: ('C', 'Controller temperature failure at pump diodes', - 'Check LCE register for details'), - 8: ('S', 'Laser start delay (60 s warmup)', - 'Laser cannot be started yet; status error LED flashing'), - 9: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 10: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 11: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 12: ('S', 'Slave controller error (remote input)', - 'Valid only for master controller coupled with a slave'), - 13: ('', 'Laserhead not found', - 'Head not connected / not found; check EMI; ignore for double-electronic slave'), - 14: ('', 'Laserhead I\u00b2C acknowledge error', - 'Check environment for strong EMI; ignore for double-electronic slave'), - 15: ('S', 'Range-Error (not critical)', - 'Input value out of range'), -} - -_LCE_FLAGS = { - 0: ('C', 'Pump diode over/under temperature', - 'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'), - 1: ('C', 'Internal hardware failure', - 'Contact Coherent'), - 2: ('C', 'Pump diode temperature out of range', - 'Actual temp >2\u00b0C off setpoint for >1 min'), - 3: ('C', 'Pump diode current critical', - 'Current set too close to current limit'), - 4: ('C', 'Pump diode temperature out of limit', - 'Pump diode temperature is out of limit'), - 5: ('S', 'Door switch open', - 'Close utility connector pin 2 permanently to pin 9 (GND)'), - 7: ('C', 'Pump diode NTC error', - 'Invalid temperature measured or NTC broken'), - 8: ('C', 'Laser diode power stage over temperature', - 'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'), - 9: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 10: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 11: ('C', 'Internal hardware failure', - 'Check environment for strong EMI; contact Coherent'), - 15: ('S', 'Range-Error (not critical)', - 'Input value out of range'), -} - -_CCE_FLAGS = { - 0: ('C', 'Resonator/SHG under/over temperature', - 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), - 1: ('C', 'Resonator/SHG NTC failure', - 'Temperature sensor broken or disconnected'), - 2: ('C', 'Resonator/SHG temperature out of range', - 'Actual temp >2\u00b0C off setpoint for >1 min'), - 3: ('C', 'Q-switch ADC / temperature readout failure', - 'Internal hardware error or no NTC connected'), - 4: ('C', 'Q-switch temperature out of range', - 'Actual temp >2\u00b0C off setpoint for >1 min'), - 5: ('C', 'Q-switch under/over temperature', - 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), - 7: ('C', 'Q-switch NTC failure', - 'Internal hardware error or no NTC connected'), - 8: ('C', 'Internal hardware failure', - 'Contact Coherent'), - 15: ('S', 'Range-Error (not critical)', - 'Input value out of range'), -} - -_SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'} - - -def _decode_register(flags_dict: dict, value: int) -> list: - """Return list of (bit, severity, description, comment) for each set bit.""" - active = [] - for bit, (sev, desc, comment) in flags_dict.items(): - if value & (1 << bit): - active.append((bit, sev, desc, comment)) - return active - +from hardware.helios_registers import ( + CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register, +) # Configure logging logging.basicConfig(level=logging.INFO) @@ -945,20 +848,20 @@ class HeliosTestApp(QMainWindow): # Decode and display individual flags lines = [] for reg_name, value, flags_dict in ( - ("LER", ler, _LER_FLAGS), - ("LCE", lce, _LCE_FLAGS), - ("CCE", cce, _CCE_FLAGS), + ("LER", ler, LER_FLAGS), + ("LCE", lce, LCE_FLAGS), + ("CCE", cce, CCE_FLAGS), ): if value is None: lines.append(f"{reg_name}: ") continue - active = _decode_register(flags_dict, value) + active = decode_register(flags_dict, value) if not active: lines.append(f"{reg_name} (raw={value}): OK — no flags set") else: lines.append(f"{reg_name} (raw={value}):") for bit, sev, desc, comment in active: - label = _SEVERITY_LABEL.get(sev, '[ ]') + label = SEVERITY_LABEL.get(sev, '[ ]') lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}") lines.append(f" → {comment}") self.text_register_decode.setPlainText("\n".join(lines)) diff --git a/sc3_aui_app.py b/sc3_aui_app.py index a510adc..f7ea655 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -337,6 +337,7 @@ class HeliosWorker(PollingQueueWorker): """ enabled_updated = pyqtSignal(bool) current_updated = pyqtSignal(int) + current_unknown = pyqtSignal() diode_temp_updated = pyqtSignal(float) pstage_temp_updated = pyqtSignal(float) qswitch_temp_updated = pyqtSignal(float) @@ -426,8 +427,19 @@ class HeliosWorker(PollingQueueWorker): self.enabled_updated.emit(self._laser.is_laser_enabled()) except Exception: pass + # A failed LDS read must not leave the last good value on screen + # looking live: that is indistinguishable from a setpoint that + # refuses to move, which is the failure this panel exists to show. + try: + current = self._laser.get_current_ma() + except Exception: + current = None + if current is None: + self.current_unknown.emit() + else: + self.current_updated.emit(current) + for getter, signal in ( - (self._laser.get_current_ma, self.current_updated), (self._laser.get_diode_temp_c, self.diode_temp_updated), (self._laser.get_power_stage_temp_c, self.pstage_temp_updated), (self._laser.get_qswitch_temp_c, self.qswitch_temp_updated), @@ -1015,6 +1027,9 @@ class HeliosWindow(QWidget): self.helios_set_current_btn.clicked.connect(self._on_set_current) self._worker.current_updated.connect(self._on_current_updated) + self._worker.current_unknown.connect( + lambda: self.helios_current_readback_label.setText("laser: ? mA") + ) self._worker.diode_temp_updated.connect( lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C") ) diff --git a/tests/test_helios_laser.py b/tests/test_helios_laser.py index b471636..517b828 100644 --- a/tests/test_helios_laser.py +++ b/tests/test_helios_laser.py @@ -235,3 +235,22 @@ def test_a_set_frequency_is_read_back(laser): def test_an_out_of_range_current_is_not_sent(laser): assert laser.set_current_ma(9000) is False assert laser.serial.written == [] + + +def test_an_unlabelled_number_is_not_taken_as_a_register_value(laser): + """A bare number answers nothing in particular. + + The controller's status registers read 32 when bit 5 is set (LER "Over + voltage laser diode", LCE "Door switch open"), and a diode current of + 32 mA is a perfectly ordinary-looking value — so a stray "32" must not + be allowed to pass for the answer to LDS. + """ + laser.serial.replies = {"LDS": ["32"]} + assert laser.get_current_ma() is None + + +def test_a_serial_number_still_comes_back_bare(laser): + """The one reply that legitimately names nothing.""" + laser.serial.replies = {"CSR": ["A1B2C3D4"], "HSR": ["7654321"]} + assert laser.get_controller_serial() == "A1B2C3D4" + assert laser.get_head_serial() == "7654321" diff --git a/tools/helios_lds_probe.py b/tools/helios_lds_probe.py new file mode 100644 index 0000000..8756617 --- /dev/null +++ b/tools/helios_lds_probe.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Raw-wire probe for the Helios pump-diode current (LDS). + +Why this exists: the laser panel reports a diode current of 32 mA that no +Set will change — and 32 is also what a status register reads with bit 5 +set (LER: "Over voltage laser diode", LCE: "Door switch open", CCE: +"Q-switch under/over temperature"). So either the controller really holds +LDS = 32 and is refusing to take a new value, or the line the driver reads +as LDS's answer belongs to some other query. Only the wire can say which, +and the driver cannot show it: it parses replies, and parsing is the thing +in question. + +Nothing here reuses the driver's reply matching. Every byte the controller +sends is printed as it arrives, with the command that preceded it, so the +transcript answers "what does LDS actually reply?" directly. + +Usage: + python3 tools/helios_lds_probe.py --port /dev/ttyUSB0 + python3 tools/helios_lds_probe.py --port /dev/ttyUSB0 --current 900 + python3 tools/helios_lds_probe.py --port /dev/ttyUSB0 --read-only + +Safety: LDS sets the pump diode's pulse current. It does not start +emission — that needs LDO 1 — and this probe never writes LDO. If it finds +the laser already enabled it refuses to write anything unless --force is +given, since changing the current under emission changes the output. +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from hardware.helios_registers import ( # noqa: E402 + CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register, +) +from hardware.serial_util import open_8n1 # noqa: E402 + +REGISTERS = {"LER": LER_FLAGS, "LCE": LCE_FLAGS, "CCE": CCE_FLAGS} + +# Reads that should not change anything, in the order the panel's poll +# issues them, plus the two the panel never asks for: LDG (pulse mode, which +# is what decides whether LDS is applied at all) and LMA (an actual-current +# read, per the manual's unit column). +READ_SWEEP = ["LER", "LCE", "CCE", "LDO", "LDG", "LDF", "LDS", "LMA", "HTR"] + +QUIET_S = 0.4 # a reply is over once the line is idle this long +LISTEN_S = 2.5 # ...but never wait longer than this for one + + +def exchange(ser, command: str, quiet_s: float = QUIET_S) -> list[tuple[float, bytes]]: + """Send `command` and return every chunk that comes back, with timings. + + No parsing, no line matching: the point is to see what the controller + sends, including anything the driver would have discarded. + """ + ser.reset_input_buffer() + ser.reset_output_buffer() + t0 = time.monotonic() + ser.write((command + "\r").encode("ascii")) + ser.flush() + + chunks: list[tuple[float, bytes]] = [] + last = time.monotonic() + while True: + now = time.monotonic() + if now - t0 >= LISTEN_S: + break + waiting = ser.in_waiting + if waiting: + chunks.append((now - t0, ser.read(waiting))) + last = time.monotonic() + elif now - last >= quiet_s: + break + else: + time.sleep(0.01) + return chunks + + +def show(command: str, chunks) -> str: + """Print one exchange and return the reply as text.""" + raw = b"".join(c for _, c in chunks) + print(f"\n > {command}") + if not raw: + print(" (no reply)") + return "" + for offset, chunk in chunks: + print(f" +{offset * 1000:6.0f} ms {chunk!r}") + text = raw.decode("ascii", errors="replace") + lines = [ln.strip() for ln in text.replace("\r", "\n").split("\n") if ln.strip()] + for line in lines: + print(f" line: {line!r}") + return text + + +def answers_for(command: str, reply: str) -> bool: + """True if some line of `reply` names `command` — i.e. it is its answer.""" + mnemonic = command.split()[0].upper() + for line in reply.replace("\r", "\n").split("\n"): + head = line.strip().split("=")[0].split() + if head and head[0].upper() == mnemonic: + return True + return False + + +def value_of(command: str, reply: str) -> int | None: + """The integer this reply reports for `command`, if it reports one.""" + mnemonic = command.split()[0].upper() + for line in reply.replace("\r", "\n").split("\n"): + head, sep, tail = line.strip().partition("=") + if not sep or head.split()[:1] != [mnemonic]: + continue + fields = tail.split() + if fields: + try: + return int(fields[0]) + except ValueError: + return None + return None + + +def decode(name: str, value: int | None): + if value is None: + print(f" {name}: no numeric value in the reply") + return + active = decode_register(REGISTERS[name], value) + print(f" {name} = {value} (0x{value:04X})" + + (" — no flags set" if not active else "")) + for bit, sev, desc, comment in active: + print(f" bit {bit:>2} ({1 << bit:>5}) {SEVERITY_LABEL.get(sev, '[ ]')} " + f"{desc} — {comment}") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--port", required=True, help="serial device, e.g. /dev/ttyUSB0") + ap.add_argument("--current", type=int, default=900, + help="LDS value to try writing (mA, default 900)") + ap.add_argument("--read-only", action="store_true", + help="query only; write nothing") + ap.add_argument("--force", action="store_true", + help="write LDS even if the laser reports itself enabled") + args = ap.parse_args() + + ser = open_8n1(args.port, baudrate=9600, timeout=1.0) + time.sleep(0.2) + ser.reset_input_buffer() + print(f"Helios probe on {args.port} — 9600 8N1\n") + print("=" * 70) + print("READ SWEEP — what each query actually answers") + print("=" * 70) + + replies: dict[str, str] = {} + for command in READ_SWEEP: + replies[command] = show(command, exchange(ser, command)) + time.sleep(0.1) + + print("\n" + "=" * 70) + print("STATUS REGISTERS") + print("=" * 70) + before = {} + for name in REGISTERS: + before[name] = value_of(name, replies[name]) + decode(name, before[name]) + + lds_before = value_of("LDS", replies["LDS"]) + print("\n" + "=" * 70) + print("LDS") + print("=" * 70) + if not replies["LDS"]: + print(" LDS answered nothing — it may be write-only on this firmware,") + print(" and the panel's read-back is coming from somewhere else.") + elif not answers_for("LDS", replies["LDS"]): + print(" The reply to LDS does not name LDS. That line belongs to") + print(" another command: the read-back is misaligned, not the laser.") + print(f" Reply was: {replies['LDS']!r}") + else: + print(f" LDS reads back as {lds_before} mA, and the reply names LDS,") + print(" so this is the controller's own value — not a stray line.") + + if args.read_only: + ser.close() + return 0 + + enabled = value_of("LDO", replies["LDO"]) + if enabled == 1 and not args.force: + print("\nLDO reads 1 — the laser is enabled and emitting. Not writing") + print("LDS; re-run with --force if changing the current now is intended.") + ser.close() + return 1 + + print("\n" + "=" * 70) + print(f"WRITE TEST — LDS {args.current}") + print("=" * 70) + show(f"LDS {args.current}", exchange(ser, f"LDS {args.current}")) + time.sleep(0.3) + after_reply = show("LDS", exchange(ser, "LDS")) + lds_after = value_of("LDS", after_reply) + + print("\n Registers after the write (bit 2 = command error, bit 15 = range error):") + for name in REGISTERS: + value = value_of(name, show(name, exchange(ser, name))) + decode(name, value) + if before[name] is not None and value is not None and value != before[name]: + print(f" ^ changed from {before[name]} — the write set this") + + print("\n" + "=" * 70) + print("VERDICT") + print("=" * 70) + if lds_after == args.current: + print(f" The controller took {args.current} mA. If the panel still shows") + print(" the old value, the problem is in the GUI, not on the wire.") + elif lds_after == lds_before: + print(f" The controller kept {lds_before} mA and ignored the write.") + print(" Check the flags above: a latched critical error (reset with") + print(" CCE 0 / LCE 0 / LER 0) or a pulse mode that does not apply a") + print(" pulse current are the two documented reasons for that.") + else: + print(f" LDS went from {lds_before} to {lds_after} — neither the old") + print(f" value nor the {args.current} mA that was asked for.") + + ser.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main())