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
+12 -4
View File
@@ -130,15 +130,21 @@ class HeliosLaser:
return lines return lines
time.sleep(0.005) time.sleep(0.005)
@staticmethod # The only replies that come back without naming what they answer.
def _value_in(line: str, mnemonic: str) -> Optional[str]: # 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 value `line` holds for `mnemonic`, or None if it isn't its reply.
The controller answers "LDF = 20000 ns". A line naming a different 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 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. 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 A line naming nothing counts only for the serial numbers, which is
numbers come back. the one reply known to come back bare.
""" """
head, sep, tail = line.partition('=') head, sep, tail = line.partition('=')
if sep: if sep:
@@ -152,7 +158,9 @@ class HeliosLaser:
fields = line.split() fields = line.split()
if fields and fields[0].upper() == mnemonic: if fields and fields[0].upper() == mnemonic:
return fields[1] if len(fields) > 1 else None return fields[1] if len(fields) > 1 else None
if mnemonic in cls.UNLABELLED_REPLIES:
return line return line
return None
def _query(self, command: str) -> Optional[str]: def _query(self, command: str) -> Optional[str]:
"""Send a query and return the value from its response. """Send a query and return the value from its response.
+102
View File
@@ -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
View File
@@ -16,106 +16,9 @@ from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from hardware.helios_laser import HeliosLaser, PulseMode from hardware.helios_laser import HeliosLaser, PulseMode
from hardware.helios_registers import (
# --------------------------------------------------------------------------- CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register,
# 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
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -945,20 +848,20 @@ class HeliosTestApp(QMainWindow):
# Decode and display individual flags # Decode and display individual flags
lines = [] lines = []
for reg_name, value, flags_dict in ( for reg_name, value, flags_dict in (
("LER", ler, _LER_FLAGS), ("LER", ler, LER_FLAGS),
("LCE", lce, _LCE_FLAGS), ("LCE", lce, LCE_FLAGS),
("CCE", cce, _CCE_FLAGS), ("CCE", cce, CCE_FLAGS),
): ):
if value is None: if value is None:
lines.append(f"{reg_name}: <read error>") lines.append(f"{reg_name}: <read error>")
continue continue
active = _decode_register(flags_dict, value) active = decode_register(flags_dict, value)
if not active: if not active:
lines.append(f"{reg_name} (raw={value}): OK — no flags set") lines.append(f"{reg_name} (raw={value}): OK — no flags set")
else: else:
lines.append(f"{reg_name} (raw={value}):") lines.append(f"{reg_name} (raw={value}):")
for bit, sev, desc, comment in active: 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" {label} bit {bit:2d} ({1 << bit:>5}): {desc}")
lines.append(f" → {comment}") lines.append(f" → {comment}")
self.text_register_decode.setPlainText("\n".join(lines)) self.text_register_decode.setPlainText("\n".join(lines))
+16 -1
View File
@@ -337,6 +337,7 @@ class HeliosWorker(PollingQueueWorker):
""" """
enabled_updated = pyqtSignal(bool) enabled_updated = pyqtSignal(bool)
current_updated = pyqtSignal(int) current_updated = pyqtSignal(int)
current_unknown = pyqtSignal()
diode_temp_updated = pyqtSignal(float) diode_temp_updated = pyqtSignal(float)
pstage_temp_updated = pyqtSignal(float) pstage_temp_updated = pyqtSignal(float)
qswitch_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()) self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception: except Exception:
pass 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 ( 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_diode_temp_c, self.diode_temp_updated),
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated), (self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
(self._laser.get_qswitch_temp_c, self.qswitch_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.helios_set_current_btn.clicked.connect(self._on_set_current)
self._worker.current_updated.connect(self._on_current_updated) 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( self._worker.diode_temp_updated.connect(
lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C") lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C")
) )
+19
View File
@@ -235,3 +235,22 @@ def test_a_set_frequency_is_read_back(laser):
def test_an_out_of_range_current_is_not_sent(laser): def test_an_out_of_range_current_is_not_sent(laser):
assert laser.set_current_ma(9000) is False assert laser.set_current_ma(9000) is False
assert laser.serial.written == [] 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"
+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())