eff589d901
_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>
257 lines
9.6 KiB
Python
257 lines
9.6 KiB
Python
"""HeliosLaser: replies that span more than one line.
|
|
|
|
Regression: every status-register query answers with the value *and* a
|
|
"Bit 15..0: ..." decode line. The driver read one line per query and threw
|
|
the rest away with reset_input_buffer(), which at 9600 baud cannot drop
|
|
bytes that are still on the wire — so from the first LER read onward every
|
|
reply was one line behind, and the panel showed a register as
|
|
"Bit 15..0: 0000 0000 0000 0010" with the reads around it timing out.
|
|
"""
|
|
import pytest
|
|
|
|
from hardware.helios_laser import HeliosLaser, PulseMode
|
|
|
|
|
|
# What the controller actually sends back, per Section 6 of the operator's
|
|
# manual and the LER/LCE/CCE tables in Section 8.
|
|
REPLIES = {
|
|
"LDO": ["LDO = 1"],
|
|
"LDF": ["LDF = 20000 ns"],
|
|
"LDS": ["LDS = 1500 mA"],
|
|
"LDG": ["LDG = 14"],
|
|
"LRE": ["LRE = 0"],
|
|
"LTA": ["LTA = 25400 m°C"],
|
|
"LTT": ["LTT = 31200 m°C"],
|
|
"EOA": ["EOA = 40100 m°C"],
|
|
"CSR": ["CSR = 1234567"],
|
|
"HSR": ["HSR = 7654321"],
|
|
# The registers are the multi-line ones.
|
|
"LER": ["LER = 0", "Bit 15..0: 0000 0000 0000 0000"],
|
|
"LCE": ["LCE = 2", "Bit 15..0: 0000 0000 0000 0010"],
|
|
"CCE": ["CCE = 0", "Bit 15..0: 0000 0000 0000 0000"],
|
|
}
|
|
|
|
|
|
class FakePort:
|
|
"""Serial stand-in that answers like the Helios controller.
|
|
|
|
``reset_input_buffer`` is deliberately a no-op: the rest of a reply is
|
|
still in flight when the driver has read its first line, so a flush
|
|
cannot remove it. The driver has to stay in step by reading what it
|
|
asked for, not by discarding what it happens to find.
|
|
"""
|
|
|
|
def __init__(self, replies=None, timeout=1.0, discard_writes=False):
|
|
self.replies = dict(REPLIES) if replies is None else replies
|
|
self.timeout = timeout
|
|
self.is_open = True
|
|
# "Commands or set values can be discarded by the controller
|
|
# unintentionally" (manual, Section 6) — the case a verified write
|
|
# exists to catch.
|
|
self.discard_writes = discard_writes
|
|
self.written: list[str] = []
|
|
self._buf = bytearray()
|
|
|
|
def _store(self, mnemonic: str, value: str):
|
|
"""Keep a written value, so a later query reads back what was set."""
|
|
if self.discard_writes:
|
|
return
|
|
previous = self.replies.get(mnemonic, [f"{mnemonic} = 0"])[0]
|
|
unit = previous.split()[3:] # "LDS = 1500 mA" -> ["mA"]
|
|
self.replies[mnemonic] = [" ".join([f"{mnemonic} =", value, *unit])]
|
|
|
|
# ── the bits of pyserial.Serial the driver uses ──────────────────────────
|
|
|
|
def write(self, data: bytes) -> int:
|
|
text = data.decode("ascii").strip()
|
|
self.written.append(text)
|
|
fields = text.split()
|
|
if len(fields) > 1:
|
|
self._store(fields[0].upper(), fields[1])
|
|
for line in self.replies.get(fields[0].upper() if fields else "", []):
|
|
self._buf += line.encode("utf-8") + b"\r\n"
|
|
return len(data)
|
|
|
|
@property
|
|
def in_waiting(self) -> int:
|
|
return len(self._buf)
|
|
|
|
def read(self, size: int = 1) -> bytes:
|
|
chunk = bytes(self._buf[:size])
|
|
del self._buf[:size]
|
|
return chunk
|
|
|
|
def read_until(self, expected: bytes = b"\n", size=None) -> bytes:
|
|
# No terminator in the buffer models the read timing out: pyserial
|
|
# returns whatever it has, which is b"" when nothing is pending.
|
|
cut = self._buf.find(expected)
|
|
cut = len(self._buf) if cut < 0 else cut + len(expected)
|
|
chunk = bytes(self._buf[:cut])
|
|
del self._buf[:cut]
|
|
return chunk
|
|
|
|
def reset_input_buffer(self):
|
|
"""No-op — see the class docstring."""
|
|
|
|
def close(self):
|
|
self.is_open = False
|
|
|
|
|
|
@pytest.fixture
|
|
def laser():
|
|
"""A connected driver on a fake port, with the idle wait taken out.
|
|
|
|
TRAILING_QUIET_S covers the ~30 ms a decode line spends on the wire at
|
|
9600 baud; the fake answers instantly, so waiting for it only slows the
|
|
suite down. Zeroing it also keeps the tests honest: they pass because
|
|
the driver reads the whole reply, not because it waited long enough.
|
|
"""
|
|
drv = HeliosLaser(timeout=1.0)
|
|
drv.serial = FakePort()
|
|
drv.is_connected = True
|
|
drv.TRAILING_QUIET_S = 0.0
|
|
drv.SET_SETTLE_S = 0.0
|
|
return drv
|
|
|
|
|
|
@pytest.fixture
|
|
def stubborn_laser():
|
|
"""A controller that answers every query but keeps its own set values."""
|
|
drv = HeliosLaser(timeout=1.0)
|
|
drv.serial = FakePort(discard_writes=True)
|
|
drv.is_connected = True
|
|
drv.TRAILING_QUIET_S = 0.0
|
|
drv.SET_SETTLE_S = 0.0
|
|
return drv
|
|
|
|
|
|
def test_status_registers_are_read_in_step(laser):
|
|
"""The regression: each register gets its own value, not the previous
|
|
register's decode line."""
|
|
assert laser.get_status_registers() == (0, 2, 0)
|
|
|
|
|
|
def test_reads_after_a_register_are_not_a_line_behind(laser):
|
|
"""A whole status poll, in the order HeliosWorker._poll_once issues it."""
|
|
assert laser.get_status_registers() == (0, 2, 0)
|
|
assert laser.is_laser_enabled() is True
|
|
assert laser.get_current_ma() == 1500
|
|
assert laser.get_diode_temp_c() == pytest.approx(25.4)
|
|
assert laser.get_power_stage_temp_c() == pytest.approx(31.2)
|
|
assert laser.get_qswitch_temp_c() == pytest.approx(40.1)
|
|
|
|
|
|
def test_decode_line_is_consumed_not_left_behind(laser):
|
|
laser.get_status_registers()
|
|
assert laser.serial.in_waiting == 0
|
|
|
|
|
|
def test_unit_suffix_is_stripped(laser):
|
|
assert laser.get_frequency_hz() == 50000 # "LDF = 20000 ns"
|
|
assert laser.get_current_ma() == 1500 # "LDS = 1500 mA"
|
|
|
|
|
|
def test_a_reply_from_an_earlier_command_is_skipped(laser):
|
|
"""An answer already in the buffer when the query goes out belongs to
|
|
whoever asked for it, and must not be returned as this query's value."""
|
|
laser.serial._buf += b"LER = 8\r\nBit 15..0: 0000 0000 0000 1000\r\n"
|
|
assert laser.get_current_ma() == 1500
|
|
|
|
|
|
def test_reply_without_a_mnemonic_is_taken_as_the_value(laser):
|
|
"""The serial numbers come back as a bare string on some firmware."""
|
|
laser.serial.replies = {"CSR": ["A1B2C3D4"]}
|
|
assert laser.get_controller_serial() == "A1B2C3D4"
|
|
|
|
|
|
def test_silent_device_reports_a_timeout(laser):
|
|
laser.serial.replies = {}
|
|
assert laser.get_current_ma() is None
|
|
assert laser._query("LTA") is None
|
|
|
|
|
|
def test_set_commands_clear_their_acknowledgement(laser):
|
|
"""A setter that leaves the controller's echo in the buffer desynchronises
|
|
the next query just as a decode line does."""
|
|
assert laser.set_laser_enable(True) is True
|
|
assert laser.serial.in_waiting == 0
|
|
assert laser.set_pulse_mode(PulseMode.CONTINUOUS_PULSING) is True
|
|
assert laser.get_current_ma() == 1500
|
|
|
|
|
|
def test_raw_command_returns_every_line(laser):
|
|
"""The diagnostics console is where a multi-line reply should be visible."""
|
|
assert laser.send_raw_command("LCE") == (
|
|
"LCE = 2\nBit 15..0: 0000 0000 0000 0010"
|
|
)
|
|
|
|
|
|
# ── Set values the controller may discard ────────────────────────────────────
|
|
#
|
|
# Section 6 of the manual: "Commands or set values can be discarded by the
|
|
# controller unintentionally. It is recommended to query the set value
|
|
# after the command is entered to confirm the actual value." A write that
|
|
# reports success without reading back leaves the panel showing a setpoint
|
|
# the laser never took, until the next poll replaces it with the old value.
|
|
|
|
def test_a_set_current_is_read_back(laser):
|
|
assert laser.set_current_ma(900) is True
|
|
assert laser.get_current_ma() == 900
|
|
assert "LDS 900" in laser.serial.written
|
|
|
|
|
|
def test_a_discarded_set_current_is_retried_then_reported(stubborn_laser):
|
|
assert stubborn_laser.set_current_ma(900) is False
|
|
# Retried, not given up on after one write.
|
|
assert stubborn_laser.serial.written.count("LDS 900") == \
|
|
stubborn_laser.SET_RETRIES
|
|
# And the controller's own value is what it still holds.
|
|
assert stubborn_laser.get_current_ma() == 1500
|
|
|
|
|
|
def test_a_set_that_takes_on_a_retry_succeeds(laser):
|
|
"""One dropped write, then the controller accepts — still a success."""
|
|
real_write = laser.serial.write
|
|
state = {"drops": 1}
|
|
|
|
def flaky(data: bytes) -> int:
|
|
if data.decode("ascii").strip().startswith("LDS ") and state["drops"]:
|
|
state["drops"] -= 1
|
|
laser.serial.written.append(data.decode("ascii").strip())
|
|
return len(data) # swallowed: nothing stored, no reply
|
|
return real_write(data)
|
|
|
|
laser.serial.write = flaky
|
|
assert laser.set_current_ma(900) is True
|
|
assert laser.get_current_ma() == 900
|
|
|
|
|
|
def test_a_set_frequency_is_read_back(laser):
|
|
assert laser.set_frequency_hz(25000) is True # 40000 ns
|
|
assert laser.get_frequency_hz() == 25000
|
|
assert "LDF 40000" in laser.serial.written
|
|
|
|
|
|
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"
|