commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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):
|
||||
self.replies = REPLIES if replies is None else replies
|
||||
self.timeout = timeout
|
||||
self.is_open = True
|
||||
self.written: list[str] = []
|
||||
self._buf = bytearray()
|
||||
|
||||
# ── 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()
|
||||
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
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user