Helios diode current: stop the status poll overwriting the setpoint
The current spin box could not hold a typed value: the 1 Hz status poll read LDS and wrote it straight into the spin box, so the operator's number was replaced by the laser's within a second — before Set could be pressed. The spin box is now seeded once on connect and belongs to the operator after that; the poll's reading goes to a read-back label beside the Set button, so the value about to be sent and the value the laser holds are separate readouts. The write itself was also unverified. Section 6 of the operator's 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." set_current_ma() wrote LDS and returned True regardless, so a discarded write looked exactly like a good one. _write_verified() now writes, reads back, and retries up to three times; set_current_ma() and set_frequency_hz() use it, and HeliosWorker reports a refusal on the status line instead of echoing the requested value. Also from the manual, recorded but not acted on: LDS accepts 0-7000 mA (the driver's 2000 mA ceiling is this rig's, not the protocol's), LDF has to be re-sent after LDG changes, and the power-monitor mnemonic is HMP — which this laser does not implement, per the operator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,19 +41,33 @@ class FakePort:
|
||||
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
|
||||
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)
|
||||
@@ -96,6 +110,18 @@ def laser():
|
||||
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
|
||||
|
||||
|
||||
@@ -158,3 +184,54 @@ def test_raw_command_returns_every_line(laser):
|
||||
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 == []
|
||||
|
||||
Reference in New Issue
Block a user