Helios: don't let an unlabelled reply pass for a register's value
_value_in()'s last resort was to accept any line it could not attribute as the answer to whatever had just been asked. That fallback exists for the serial numbers, which come back bare — but it applied to every query, so a stray "32" could be read as a diode current of 32 mA. 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"), which is exactly the value the panel is stuck on. Only CSR and HSR now accept an unlabelled reply; every other read has to see its own mnemonic in the line. A read that cannot be attributed returns None, and the panel shows "laser: ? mA" instead of leaving the last good value on screen looking live — a stale reading and a setpoint that refuses to move are indistinguishable otherwise. tools/helios_lds_probe.py is the diagnostic for the underlying question: it talks to the controller with no reply parsing at all and prints every byte, so the transcript says whether LDS answers for itself, whether the write is taken, and which flags the registers hold before and after. The status-register tables move to hardware/helios_registers.py so the probe can decode them without importing the Qt app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
+8
-105
@@ -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}: <read error>")
|
||||
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))
|
||||
|
||||
+16
-1
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user