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]: def enumerate_ports(self) -> list[str]:
"""Return list of available serial port names.""" """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]: def detect_wafer(self, port: str) -> Optional[WaferInfo]:
"""Try to detect a wafer on the given port. """Try to detect a wafer on the given port.
+13
View File
@@ -52,6 +52,7 @@ class SerialPort:
@contextmanager @contextmanager
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]: def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
"""Open the serial port, yield it, then close on exit.""" """Open the serial port, yield it, then close on exit."""
try:
port = pyserial.Serial( port = pyserial.Serial(
self._port_name, self._port_name,
self.BAUDRATE, self.BAUDRATE,
@@ -62,6 +63,18 @@ class SerialPort:
rtscts=False, rtscts=False,
dsrdtr=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: if timeout is not None:
port.timeout = timeout port.timeout = timeout
try: 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 from __future__ import annotations
import logging import logging
import threading import threading
import time
from typing import Callable from typing import Callable
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.serialcomm.data_parser import _convert_hex_to_temp
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ParseLine = Callable[[str, int], Frame] ParseFrame = Callable[[bytes, int], Frame]
class StreamReader: class StreamReader:
def __init__(self, transport, parse_line: ParseLine, def __init__(self, transport, parse_frame: ParseFrame,
on_frame: Callable[[Frame], None], 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._transport = transport
self._parse = parse_line self._parse = parse_frame
self._on_frame = on_frame self._on_frame = on_frame
self._on_error = on_error or (lambda e: None) self._on_error = on_error or (lambda e: None)
self._family_code = family_code or "A"
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop = threading.Event() self._stop = threading.Event()
self._seq = 0
self.error_count = 0 self.error_count = 0
def start(self) -> None: def start(self) -> None:
@@ -29,28 +32,206 @@ 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_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: def _run(self) -> None:
try: try:
while not self._stop.is_set(): # Check the first few bytes to determine if the wafer is streaming binary, ASCII hex dump, or text lines.
raw = self._transport.readline() peek_buf = bytearray()
if not raw: while not self._stop.is_set() and len(peek_buf) < 32:
b = self._transport.read(1)
if not b:
continue continue
text = raw.decode(errors="ignore").strip() peek_buf.extend(b)
if not text: if b[0] == 10: # newline
continue break
try:
frame= self._parse(text, self._seq) if self._stop.is_set() or not peek_buf:
self._seq += 1 return
self._on_frame(frame)
except Exception as exc: detect_buf = bytes(c for c in peek_buf if c not in (10, 13, 0))
self.error_count += 1 if not detect_buf:
self._on_error(exc) 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: finally:
try: try:
self._transport.close() self._transport.close()
except Exception: except Exception:
pass 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: def stop(self) -> None:
self._stop.set() self._stop.set()
if self._thread is not None and self._thread.is_alive(): 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 from pygui.serialcomm.stream_reader import StreamReader
class FakeTransport: class FakeTransport:
"""Yield canned line then blocks (returns b'').""" """Yield canned data then blocks (returns b'')."""
def __init__(self, lines): self._lines = list(lines); self.closed = False 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): def readline(self):
time.sleep(0.001) 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 close(self): self.closed = True
def parse_line(raw: str, seq: int) -> Frame: def parse_line(raw: str, seq: int) -> Frame: