76ed7828cc
The rig transcript (tools/helios_lds_probe.py) settles where the panel's
32 mA came from, and it was never the laser: LDS reads 100 mA, answers for
itself, and takes a write of 900 mA on the first attempt. 32 is LCE — bit
5, "Door switch open" — arriving in the diode-current field.
Every reply is CRLF-terminated and padded with a blank line or two:
b'LDS = 100 mA\r\n\r\n'
b'LCE = 32\r\nBit 15..0: 0000 0000 0010 0000\r\n\r\n\r\n'
_read_line() read up to CR, so the final LF of every reply stayed in the
buffer, and the next read waited out the whole port timeout for a CR that
only the next command would bring. A second of dead air per query: replayed
against the transcript's byte timing, one status poll took 8.6 s against
the 1 s interval that schedules it. That is also what let the values drift
apart — a query whose deadline goes to a blocked read gives up while its
own reply is still on the wire, the next query flushes the port mid-line,
and the fragment it reads is " 32", the value half of LCE's reply.
Lines are now framed on CR, LF or CRLF out of a receive buffer that
_discard_input() clears along with the port, so nothing survives a flush
half-read. The same replay now polls in 0.59 s.
Tests carry the transcript's real framing (padded values, trailing blank
lines) instead of the tidied "LER = 0" it was guessed to be, plus the two
regressions: a late fragment must not become the next query's value, and a
reply must be readable without waiting out the port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 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, transcribed from a session with
|
|
# the laser (tools/helios_lds_probe.py): CRLF line ends, the value padded
|
|
# out to a fixed width, and one or two blank lines closing every reply.
|
|
#
|
|
# b'LDS = 100 mA\r\n\r\n'
|
|
# b'LCE = 32\r\nBit 15..0: 0000 0000 0010 0000\r\n\r\n\r\n'
|
|
#
|
|
# The blank lines matter: a reader that stops at CR leaves the LF of the
|
|
# last one behind, and the next read waits out the port timeout for a CR
|
|
# that only the next command will bring.
|
|
_PAD = [""]
|
|
_REGISTER_PAD = ["", ""]
|
|
|
|
REPLIES = {
|
|
"LDO": ["LDO = 1 "] + _PAD,
|
|
"LDF": ["LDF = 20000 ns"] + _PAD,
|
|
"LDS": ["LDS = 1500 mA"] + _PAD,
|
|
"LDG": ["LDG = 14 "] + _PAD,
|
|
"LRE": ["LRE = 0 "] + _PAD,
|
|
"LTA": ["LTA = 25400 m°C"] + _PAD,
|
|
"LTT": ["LTT = 31200 m°C"] + _PAD,
|
|
"EOA": ["EOA = 40100 m°C"] + _PAD,
|
|
"CSR": ["CSR = 1234567"] + _PAD,
|
|
"HSR": ["HSR = 7654321"] + _PAD,
|
|
# The registers are the multi-line ones.
|
|
"LER": ["LER = 0", "Bit 15..0: 0000 0000 0000 0000"] + _REGISTER_PAD,
|
|
"LCE": ["LCE = 2", "Bit 15..0: 0000 0000 0000 0010"] + _REGISTER_PAD,
|
|
"CCE": ["CCE = 0", "Bit 15..0: 0000 0000 0000 0000"] + _REGISTER_PAD,
|
|
}
|
|
|
|
|
|
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"
|
|
|
|
|
|
class SplitReplyPort(FakePort):
|
|
"""Answers LCE in two pieces, the tail arriving after the next command.
|
|
|
|
That is what the wire looks like when a query gives up early: at 9600
|
|
baud the rest of the reply is still coming, and reset_input_buffer()
|
|
cannot drop bytes that have not arrived. The fragment left over is
|
|
" 32" — the value half of "LCE = 32", which is a plausible
|
|
diode current and was read as one.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._late = b""
|
|
|
|
def write(self, data: bytes) -> int:
|
|
text = data.decode("ascii").strip()
|
|
self.written.append(text)
|
|
mnemonic = text.split()[0].upper() if text.split() else ""
|
|
# Whatever is asked next, the last reply's tail lands in front of it.
|
|
self._buf += self._late
|
|
self._late = b""
|
|
if mnemonic == "LCE":
|
|
self._buf += b"LCE =" # ...and no line ending yet
|
|
self._late = b" 32\r\n\r\n\r\n"
|
|
return len(data)
|
|
for line in self.replies.get(mnemonic, []):
|
|
self._buf += line.encode("utf-8") + b"\r\n"
|
|
return len(data)
|
|
|
|
|
|
def test_a_late_fragment_is_not_the_next_query_s_value():
|
|
"""The regression this branch exists for.
|
|
|
|
LCE's reply is cut in half, so the register read gives up. The tail
|
|
arrives while the *next* query is being answered, and "32" is what the
|
|
panel showed as the pump diode current — LCE bit 5, "Door switch open",
|
|
read as milliamps.
|
|
"""
|
|
drv = HeliosLaser(timeout=1.0)
|
|
drv.serial = SplitReplyPort()
|
|
drv.is_connected = True
|
|
drv.TRAILING_QUIET_S = 0.0
|
|
|
|
assert drv._query_int("LCE") is None # cut off mid-reply
|
|
assert drv.get_current_ma() == 1500 # not 32
|
|
|
|
|
|
def test_a_reply_is_read_without_waiting_out_the_port(laser):
|
|
"""Nothing is left in either buffer once a reply has been read.
|
|
|
|
A leftover LF costs a whole port timeout on the next read, which is
|
|
what made a status poll take ~8.6 s against a 1 s interval.
|
|
"""
|
|
laser.timeout = 0.01 # a wait would show up as a failure below
|
|
assert laser.get_status_registers() == (0, 2, 0)
|
|
assert laser.get_current_ma() == 1500
|
|
assert laser.serial.in_waiting == 0
|
|
assert laser._rx == bytearray()
|