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:
Thomas Ales
2026-09-08 07:48:06 -05:00
parent c7eb33891c
commit eff589d901
6 changed files with 389 additions and 111 deletions
+231
View File
@@ -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())