feat(serialcomm): implement stream auto-detection, fallback baudrate, and virtual port discovery

- Add automatic format detection (binary, ASCII hex dump, or text line) in StreamReader based on stream preambles.
- Implement fallback to 115200 baudrate when 888888 ioctl fails (critical for macOS PTYs).
- Parse binary streams (X family) with little-endian sequence/length headers and Modbus CRC-16 checks.
- Discover and prioritize virtual serial ports (from running socat instances) in enumerate_ports using lsof.
- Update FakeTransport to mock read() and readline() calls for unit test compatibility.
This commit is contained in:
jack
2026-06-15 11:11:00 -07:00
parent f89a735d51
commit 5b186df888
4 changed files with 297 additions and 33 deletions
+52 -1
View File
@@ -37,7 +37,58 @@ class DeviceService:
def enumerate_ports(self) -> list[str]:
"""Return list of available serial port names."""
return [p.device for p in serial.tools.list_ports.comports()]
hardware_ports = [p.device for p in serial.tools.list_ports.comports()]
virtual_ports = []
# On macOS/Linux, also detect virtual loopback serial ports (PTYs) opened by socat
import platform
import subprocess
import re
if platform.system() in ("Darwin", "Linux"):
try:
# Find PTYs opened by socat
result = subprocess.run(
["lsof", "-c", "socat"],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
for line in result.stdout.splitlines():
parts = line.strip().split()
if len(parts) >= 9:
fd = parts[3]
# Exclude standard FDs (0, 1, 2) which are typically the controlling terminal
if fd.startswith(('0', '1', '2')):
continue
name = parts[-1]
match = re.search(r'(/dev/(?:ttys\d+|pts/\d+))\b', name)
if match:
port = match.group(1)
if port not in virtual_ports:
virtual_ports.append(port)
except Exception as e:
log.debug("Error listing virtual ports via lsof: %s", e)
# Prioritize physical USB serial interfaces first -> generic hardware -> virtual ports
# Virtual ports (PTYs detected via lsof) should NOT appear in hardware_ports,
# so we filter them out explicitly.
virtual_set = set(virtual_ports)
ordered_ports = []
for p in hardware_ports:
if "usbserial" in p.lower() or "usb" in p.lower() or "ttyusb" in p.lower():
if p not in ordered_ports:
ordered_ports.append(p)
for p in hardware_ports:
if p not in ordered_ports and p not in virtual_set:
ordered_ports.append(p)
for p in virtual_ports:
if p not in ordered_ports:
ordered_ports.append(p)
return ordered_ports
def detect_wafer(self, port: str) -> Optional[WaferInfo]:
"""Try to detect a wafer on the given port.
+13
View File
@@ -52,6 +52,7 @@ class SerialPort:
@contextmanager
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
"""Open the serial port, yield it, then close on exit."""
try:
port = pyserial.Serial(
self._port_name,
self.BAUDRATE,
@@ -62,6 +63,18 @@ class SerialPort:
rtscts=False,
dsrdtr=False,
)
except Exception as exc:
log.warning("Baud rate %d failed on %s, falling back to 115200: %s", self.BAUDRATE, self._port_name, exc)
port = pyserial.Serial(
self._port_name,
115200,
parity=pyserial.PARITY_NONE,
bytesize=8,
stopbits=pyserial.STOPBITS_ONE,
xonxoff=False,
rtscts=False,
dsrdtr=False,
)
if timeout is not None:
port.timeout = timeout
try:
+200 -19
View File
@@ -1,27 +1,30 @@
"""Continuos serial frame reader (Live-mode source)"""
"""Continuous serial frame reader (Live-mode source)"""
from __future__ import annotations
import logging
import threading
import time
from typing import Callable
from pygui.backend.models.frame import Frame
from pygui.serialcomm.data_parser import _convert_hex_to_temp
log = logging.getLogger(__name__)
ParseLine = Callable[[str, int], Frame]
ParseFrame = Callable[[bytes, int], Frame]
class StreamReader:
def __init__(self, transport, parse_line: ParseLine,
def __init__(self, transport, parse_frame: ParseFrame,
on_frame: Callable[[Frame], None],
on_error: Callable[[Exception], None] | None = None ) -> None:
on_error: Callable[[Exception], None] | None = None,
family_code: str = "A") -> None:
self._transport = transport
self._parse = parse_line
self._parse = parse_frame
self._on_frame = on_frame
self._on_error = on_error or (lambda e: None)
self._family_code = family_code or "A"
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._seq = 0
self.error_count = 0
def start(self) -> None:
@@ -29,28 +32,206 @@ class StreamReader:
self._thread = threading.Thread(target=self._run, daemon = True)
self._thread.start()
def _read_exact(self, count: int) -> bytes | None:
"""Read exactly `count` bytes. Returns None if timed out/stopped."""
buf = bytearray()
while len(buf) < count and not self._stop.is_set():
b = self._transport.read(1)
if not b:
continue
buf.extend(b)
if self._stop.is_set():
return None
return bytes(buf)
def _run(self) -> None:
try:
while not self._stop.is_set():
raw = self._transport.readline()
if not raw:
# Check the first few bytes to determine if the wafer is streaming binary, ASCII hex dump, or text lines.
peek_buf = bytearray()
while not self._stop.is_set() and len(peek_buf) < 32:
b = self._transport.read(1)
if not b:
continue
text = raw.decode(errors="ignore").strip()
if not text:
continue
try:
frame= self._parse(text, self._seq)
self._seq += 1
self._on_frame(frame)
except Exception as exc:
self.error_count += 1
self._on_error(exc)
peek_buf.extend(b)
if b[0] == 10: # newline
break
if self._stop.is_set() or not peek_buf:
return
detect_buf = bytes(c for c in peek_buf if c not in (10, 13, 0))
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):
self._run_binary(bytes(peek_buf))
elif b"," in detect_buf:
self._run_text_lines(bytes(peek_buf))
else:
self._run_ascii(bytes(peek_buf))
finally:
try:
self._transport.close()
except Exception:
pass
def _run_binary(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting binary stream parsing")
buf = bytearray(initial_bytes)
while not self._stop.is_set():
idx = 0
synced = False
while idx < len(buf) - 1:
if buf[idx] == 0xAA and buf[idx+1] == 0x88:
synced = True
break
idx += 1
if synced:
buf = buf[idx:]
while len(buf) < 7 and not self._stop.is_set():
b = self._transport.read(1)
if b:
buf.extend(b)
if self._stop.is_set():
return
payload_len = int.from_bytes(buf[5:7], byteorder='little')
total_packet_len = 2 + 5 + payload_len + 2
while len(buf) < total_packet_len and not self._stop.is_set():
b = self._transport.read(1)
if b:
buf.extend(b)
if self._stop.is_set():
return
header = buf[2:7]
seq = int.from_bytes(header[1:3], byteorder='little')
payload = buf[7:7+payload_len]
try:
frame = self._parse(payload, seq)
self._on_frame(frame)
except Exception as exc:
self.error_count += 1
if self.error_count <= 10:
log.warning("Parse error on binary payload: %s", exc)
self._on_error(exc)
buf = buf[total_packet_len:]
else:
if len(buf) > 0 and buf[-1] == 0xAA:
buf = bytearray([0xAA])
else:
buf.clear()
b = self._transport.read(1)
if b:
buf.extend(b)
def _run_ascii(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting ASCII hex dump stream parsing")
valid_words = 80 if self._family_code == "X" else 244
block_words = 256
word_char_len = 4
hex_chars = bytearray()
if initial_bytes:
for b in initial_bytes:
if chr(b) in "0123456789ABCDEFabcdef":
hex_chars.append(b)
elif b == 0xAA:
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
while not self._stop.is_set():
word_buffer = []
eof = False
for _ in range(block_words):
word_str = ""
for _ in range(word_char_len):
c = read_next_hex_char()
if c is None:
eof = True
break
word_str += c
if eof or len(word_str) < word_char_len:
eof = True
break
word_buffer.append(word_str)
if eof:
if hex_chars and hex_chars[0] == 0xAA:
log.info("StreamReader: Detected 0xAA, switching to binary mode.")
self._run_binary(bytes(hex_chars))
break
valid_hex_words = word_buffer[:valid_words]
values = []
for hex_val in valid_hex_words:
try:
swapped = hex_val[2:4] + hex_val[0:2]
t = _convert_hex_to_temp(swapped, self._family_code)
values.append(t)
except Exception:
values.append(0.0)
try:
frame = Frame(seq=seq, time=seq * 0.1, values=values)
self._on_frame(frame)
seq += 1
except Exception as exc:
log.error("Error emitting ASCII frame: %s", exc)
time.sleep(0.05)
def _run_text_lines(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting line-by-line ASCII text parsing")
buf = bytearray(initial_bytes)
seq = 0
while not self._stop.is_set():
if b"\n" in buf:
line_bytes, _, buf = buf.partition(b"\n")
line = line_bytes.decode('utf-8', errors='ignore').strip()
if line:
try:
frame = self._parse(line, seq)
if frame:
self._on_frame(frame)
seq += 1
except Exception as exc:
self.error_count += 1
self._on_error(exc)
continue
b = self._transport.read(1)
if not b:
time.sleep(0.005)
continue
buf.extend(b)
def stop(self) -> None:
self._stop.set()
if self._thread is not None and self._thread.is_alive():
+22 -3
View File
@@ -3,11 +3,30 @@ from pygui.backend.models.frame import Frame
from pygui.serialcomm.stream_reader import StreamReader
class FakeTransport:
"""Yield canned line then blocks (returns b'')."""
def __init__(self, lines): self._lines = list(lines); self.closed = False
"""Yield canned data then blocks (returns b'')."""
def __init__(self, lines):
self._data = b"".join(lines)
self._pos = 0
self.closed = False
def read(self, n=1):
time.sleep(0.001)
if self._pos >= len(self._data):
return b""
res = self._data[self._pos:self._pos+n]
self._pos += n
return res
def readline(self):
time.sleep(0.001)
return self._lines.pop(0) if self._lines else b""
if self._pos >= len(self._data):
return b""
idx = self._data.find(b"\n", self._pos)
if idx == -1:
res = self._data[self._pos:]
self._pos = len(self._data)
return res
res = self._data[self._pos:idx+1]
self._pos = idx + 1
return res
def close(self): self.closed = True
def parse_line(raw: str, seq: int) -> Frame: