Helios: read CRLF replies as lines, not as CR plus dead air
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>
This commit is contained in:
@@ -34,6 +34,7 @@ class HeliosLaser:
|
||||
self.timeout = timeout
|
||||
self.serial = None
|
||||
self.is_connected = False
|
||||
self._rx = bytearray() # bytes read off the port, not yet a line
|
||||
|
||||
@staticmethod
|
||||
def list_available_ports() -> List[str]:
|
||||
@@ -52,6 +53,7 @@ class HeliosLaser:
|
||||
|
||||
try:
|
||||
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
||||
self._rx.clear()
|
||||
time.sleep(0.1) # Allow time for connection to stabilize
|
||||
self.is_connected = True
|
||||
logger.info(f"Connected to Helios laser on {self.port}")
|
||||
@@ -108,20 +110,58 @@ class HeliosLaser:
|
||||
TRAILING_QUIET_S = 0.05 # the line counts as idle after this long
|
||||
MAX_REPLY_LINES = 8
|
||||
|
||||
def _discard_input(self):
|
||||
"""Drop anything unread, on the wire and already taken off it."""
|
||||
self._rx.clear()
|
||||
self.serial.reset_input_buffer()
|
||||
|
||||
def _read_line(self) -> Optional[str]:
|
||||
"""One CR-terminated line without its framing; None if nothing came."""
|
||||
raw = self.serial.read_until(b'\r')
|
||||
if not raw:
|
||||
return None
|
||||
return raw.decode('ascii', errors='replace').strip()
|
||||
"""One line, however it is framed; None if nothing came in time.
|
||||
|
||||
The controller ends every line with CRLF and pads a reply with a
|
||||
blank line or two:
|
||||
|
||||
b'LDS = 100 mA\r\n\r\n'
|
||||
|
||||
Reading up to CR alone leaves the trailing LF behind, and the next
|
||||
read then waits out the whole port timeout for a CR that will not
|
||||
come until some later command is answered. That was a second of
|
||||
dead air per query — a status poll took ~8.6 s against the 1 s
|
||||
interval that schedules it — and worse, a query that spends its
|
||||
deadline blocked gives up while its own reply is still arriving.
|
||||
The next query then flushes the port mid-line, and the fragment it
|
||||
reads is a bare number: "LCE = 32" cut after the "=" is where a
|
||||
diode current of 32 mA came from.
|
||||
"""
|
||||
deadline = time.monotonic() + self.timeout
|
||||
while True:
|
||||
cut = min((i for i in (self._rx.find(b'\r'), self._rx.find(b'\n'))
|
||||
if i >= 0), default=-1)
|
||||
if cut >= 0:
|
||||
line = bytes(self._rx[:cut])
|
||||
# CRLF is one terminator, not an empty line between two.
|
||||
end = cut + (2 if self._rx[cut:cut + 2] == b'\r\n' else 1)
|
||||
del self._rx[:end]
|
||||
return line.decode('ascii', errors='replace').strip()
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
chunk = self.serial.read(self.serial.in_waiting or 1)
|
||||
if not chunk:
|
||||
return None # port timeout: nothing more is coming
|
||||
self._rx += chunk
|
||||
|
||||
def _read_pending_lines(self) -> List[str]:
|
||||
"""Every further line the controller sends before the line goes quiet."""
|
||||
lines: List[str] = []
|
||||
deadline = time.monotonic() + self.TRAILING_QUIET_S
|
||||
while True:
|
||||
if self.serial.in_waiting:
|
||||
# What has already arrived is read whatever the quiet window
|
||||
# says: the window is for deciding when to stop waiting, not
|
||||
# for leaving a line in the buffer to confuse the next query.
|
||||
if self._rx or self.serial.in_waiting:
|
||||
line = self._read_line()
|
||||
if line is None:
|
||||
return lines # a partial line, nothing behind it
|
||||
if line:
|
||||
lines.append(line)
|
||||
deadline = time.monotonic() + self.TRAILING_QUIET_S
|
||||
@@ -175,7 +215,7 @@ class HeliosLaser:
|
||||
mnemonic = fields[0].upper() if fields else ""
|
||||
try:
|
||||
# Anything volunteered while the port was idle answers no command.
|
||||
self.serial.reset_input_buffer()
|
||||
self._discard_input()
|
||||
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
@@ -395,7 +435,7 @@ class HeliosLaser:
|
||||
logger.error("Not connected to laser")
|
||||
return None
|
||||
try:
|
||||
self.serial.reset_input_buffer()
|
||||
self._discard_input()
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
first = self._read_line()
|
||||
|
||||
Reference in New Issue
Block a user