feat(stream_reader): optimize serial stream protocol detection. reduce CPU busy-spins

This commit is contained in:
jack
2026-06-15 11:56:09 -07:00
parent b34b920759
commit 5b1c924d35
+96 -62
View File
@@ -32,9 +32,35 @@ class StreamReader:
self._thread = threading.Thread(target=self._run, daemon = True) self._thread = threading.Thread(target=self._run, daemon = True)
self._thread.start() self._thread.start()
def _read_chunk(self, min_bytes: int = 1) -> bytes:
"""Read a chunk of available bytes from the transport.
Reads up to 256 bytes in one shot instead of byte-at-a-time,
falling back to single-byte reads with a yield when nothing is
immediately available."""
chunk = self._transport.read(max(min_bytes, 256))
if chunk:
return chunk
# Nothing available right now yield briefly to avoid busy-spin.
b = self._transport.read(1)
if not b:
time.sleep(0.005)
return b
def _accumulate(self, needed: int) -> bytearray:
"""Accumulate *needed* bytes using buffered reads."""
buf = bytearray()
while len(buf) < needed and not self._stop.is_set():
chunk = self._transport.read(needed - len(buf))
if not chunk:
time.sleep(0.005)
continue
buf.extend(chunk)
return buf
def _run(self) -> None: def _run(self) -> None:
try: try:
# Check the first few bytes to determine if the wafer is streaming binary, ASCII hex dump, or text lines. # Peek at the first few bytes to determine protocol.
peek_buf = bytearray() peek_buf = bytearray()
while not self._stop.is_set() and len(peek_buf) < 32: while not self._stop.is_set() and len(peek_buf) < 32:
b = self._transport.read(1) b = self._transport.read(1)
@@ -47,16 +73,24 @@ class StreamReader:
if self._stop.is_set() or not peek_buf: if self._stop.is_set() or not peek_buf:
return return
detect_buf = bytes(c for c in peek_buf if c not in (10, 13, 0)) raw = bytes(peek_buf)
if not detect_buf:
detect_buf = bytes(peek_buf)
if detect_buf.startswith(b"\xaa\x88") or (len(detect_buf) >= 2 and detect_buf[0] == 0xAA and detect_buf[1] == 0x88): # Binary sync bytes are the strongest signal.
self._run_binary(bytes(peek_buf)) if raw.startswith(b"\xaa\x88"):
elif b"," in detect_buf: self._run_binary(raw)
self._run_text_lines(bytes(peek_buf)) return
else:
self._run_ascii(bytes(peek_buf)) # Text-lines (CSV) requires BOTH a newline AND commas in the
# pre-newline content. This avoids misclassifying a binary
# payload that happens to contain 0x2C as text.
has_newline = b"\n" in raw or b"\r" in raw
line_before_nl = raw.split(b"\n")[0].split(b"\r")[0]
if has_newline and b"," in line_before_nl:
self._run_text_lines(raw)
return
# Default: ASCII hex dump stream.
self._run_ascii(raw)
finally: finally:
try: try:
self._transport.close() self._transport.close()
@@ -68,20 +102,21 @@ class StreamReader:
buf = bytearray(initial_bytes) buf = bytearray(initial_bytes)
while not self._stop.is_set(): while not self._stop.is_set():
# Find sync marker in buffer.
idx = 0 idx = 0
synced = False synced = False
while idx < len(buf) - 1: while idx < len(buf) - 1:
if buf[idx] == 0xAA and buf[idx+1] == 0x88: if buf[idx] == 0xAA and buf[idx + 1] == 0x88:
synced = True synced = True
break break
idx += 1 idx += 1
if synced: if synced:
buf = buf[idx:] buf = buf[idx:]
# Accumulate header (5 bytes after sync).
while len(buf) < 7 and not self._stop.is_set(): while len(buf) < 7 and not self._stop.is_set():
b = self._transport.read(1) chunk = self._read_chunk()
if b: buf.extend(chunk)
buf.extend(b)
if self._stop.is_set(): if self._stop.is_set():
return return
@@ -89,17 +124,18 @@ class StreamReader:
payload_len = int.from_bytes(buf[5:7], byteorder='little') payload_len = int.from_bytes(buf[5:7], byteorder='little')
total_packet_len = 2 + 5 + payload_len + 2 total_packet_len = 2 + 5 + payload_len + 2
while len(buf) < total_packet_len and not self._stop.is_set(): # Accumulate the rest of the packet in one buffered read.
b = self._transport.read(1) remaining = total_packet_len - len(buf)
if b: if remaining > 0:
buf.extend(b) extra = self._accumulate(remaining)
buf.extend(extra)
if self._stop.is_set(): if self._stop.is_set():
return return
header = buf[2:7] header = buf[2:7]
seq = int.from_bytes(header[1:3], byteorder='little') seq = int.from_bytes(header[1:3], byteorder='little')
payload = buf[7:7+payload_len] payload = buf[7:7 + payload_len]
try: try:
frame = self._parse(payload, seq) frame = self._parse(payload, seq)
@@ -112,14 +148,14 @@ class StreamReader:
buf = buf[total_packet_len:] buf = buf[total_packet_len:]
else: else:
# Resync: keep only trailing 0xAA if present, then read more.
if len(buf) > 0 and buf[-1] == 0xAA: if len(buf) > 0 and buf[-1] == 0xAA:
buf = bytearray([0xAA]) buf = bytearray([0xAA])
else: else:
buf.clear() buf.clear()
b = self._transport.read(1) chunk = self._read_chunk()
if b: buf.extend(chunk)
buf.extend(b)
def _run_ascii(self, initial_bytes: bytes) -> None: def _run_ascii(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting ASCII hex dump stream parsing") log.info("StreamReader: starting ASCII hex dump stream parsing")
@@ -127,6 +163,7 @@ class StreamReader:
valid_words = 80 if self._family_code == "X" else 244 valid_words = 80 if self._family_code == "X" else 244
block_words = 256 block_words = 256
word_char_len = 4 word_char_len = 4
chars_needed = block_words * word_char_len # total hex chars per block
hex_chars = bytearray() hex_chars = bytearray()
if initial_bytes: if initial_bytes:
@@ -136,44 +173,43 @@ class StreamReader:
elif b == 0xAA: elif b == 0xAA:
hex_chars.append(b) hex_chars.append(b)
def read_next_hex_char() -> str | None:
if hex_chars:
val = chr(hex_chars.pop(0))
return val
while not self._stop.is_set():
b = self._transport.read(1)
if not b:
continue
if b[0] == 0xAA:
hex_chars.extend(b)
return None
c = chr(b[0])
if c in "0123456789ABCDEFabcdef":
return c
return None
seq = 0 seq = 0
while not self._stop.is_set(): while not self._stop.is_set():
word_buffer = [] # Accumulate a full block of hex characters using buffered reads.
eof = False while len(hex_chars) < chars_needed and not self._stop.is_set():
for _ in range(block_words): chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
word_str = "" for byte in chunk:
for _ in range(word_char_len): if byte == 0xAA:
c = read_next_hex_char() # Sync marker appeared hand off to binary parser.
if c is None: hex_chars.append(byte)
eof = True
break break
word_str += c c = chr(byte)
if eof or len(word_str) < word_char_len: if c in "0123456789ABCDEFabcdef":
eof = True hex_chars.append(c)
break else:
word_buffer.append(word_str) continue
break # broke out of inner loop due to 0xAA
if eof: # Check for early termination (0xAA sync marker anywhere in buffer).
if hex_chars and hex_chars[0] == 0xAA: aa_idx = hex_chars.find(bytes([0xAA]))
log.info("StreamReader: Detected 0xAA, switching to binary mode.") if aa_idx >= 0:
self._run_binary(bytes(hex_chars)) log.info("StreamReader: Detected 0xAA at position %d, switching to binary mode.", aa_idx)
self._run_binary(hex_chars[aa_idx:])
return
# Parse words from the accumulated hex characters.
word_buffer = []
for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
word_str = hex_chars[i:i + word_char_len]
if len(word_str) < word_char_len:
break break
word_buffer.append(bytes(word_str))
if not word_buffer:
time.sleep(0.01)
continue
hex_chars = hex_chars[len(word_buffer) * word_char_len:]
valid_hex_words = word_buffer[:valid_words] valid_hex_words = word_buffer[:valid_words]
values = [] values = []
@@ -198,7 +234,8 @@ class StreamReader:
seq = 0 seq = 0
while not self._stop.is_set(): while not self._stop.is_set():
if b"\n" in buf: # Drain complete lines from the buffer.
while b"\n" in buf:
line_bytes, _, buf = buf.partition(b"\n") line_bytes, _, buf = buf.partition(b"\n")
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if line: if line:
@@ -210,13 +247,10 @@ class StreamReader:
except Exception as exc: except Exception as exc:
self.error_count += 1 self.error_count += 1
self._on_error(exc) self._on_error(exc)
continue
b = self._transport.read(1) # Read more data using buffered reads.
if not b: chunk = self._read_chunk()
time.sleep(0.005) buf.extend(chunk)
continue
buf.extend(b)
def stop(self) -> None: def stop(self) -> None:
self._stop.set() self._stop.set()