from __future__ import annotations import logging import queue import threading import time logger = logging.getLogger(__name__) from dataclasses import dataclass from typing import Optional, Tuple import serial from PySide6.QtCore import QObject, QThread, Signal, Slot from ameba_control_panel import config from ameba_control_panel.services.line_parser import decode_line @dataclass class SerialState: port: str baudrate: int connected: bool error: Optional[str] = None class _SerialWorker(QThread): line_received = Signal(str, str) # text, direction status_changed = Signal(object) # SerialState def __init__(self, port: str, baudrate: int, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._port = port self._baudrate = baudrate self._write_queue: "queue.SimpleQueue[Tuple[bytes, bool]]" = queue.SimpleQueue() self._running = threading.Event() self._serial: Optional[serial.Serial] = None def run(self) -> None: try: self._serial = serial.Serial(self._port, self._baudrate, timeout=0.05) self.status_changed.emit(SerialState(self._port, self._baudrate, True)) except Exception as exc: # noqa: BLE001 logger.error("Serial open failed %s@%d: %s", self._port, self._baudrate, exc) self.status_changed.emit(SerialState(self._port, self._baudrate, False, str(exc))) return self._running.set() partial_line = "" partial_line_ts = 0.0 partial_hold_timeout = config.PARTIAL_LINE_HOLD_SEC try: while self._running.is_set(): # writes try: while True: payload, log_tx = self._write_queue.get_nowait() try: self._serial.write(payload) except serial.SerialException: logger.error("Serial write failed on %s", self._port) self._running.clear() break if log_tx: try: text = payload.decode(errors="ignore").rstrip("\r\n") except Exception: text = repr(payload) self.line_received.emit(text, "tx") except queue.Empty: pass # reads — accumulate partial lines until \n or stale timeout raw = self._serial.readline() if raw: try: text = decode_line(raw) except Exception: text = repr(raw) if raw.endswith(b"\n") or raw.endswith(b"\r"): full = (partial_line + text).strip("\r\n") partial_line = "" if full: self.line_received.emit(full, "rx") else: partial_line += text partial_line_ts = time.monotonic() elif partial_line and (time.monotonic() - partial_line_ts) >= partial_hold_timeout: self.line_received.emit(partial_line.strip("\r\n"), "rx") partial_line = "" finally: if partial_line: self.line_received.emit(partial_line.strip("\r\n"), "rx") if self._serial: try: self._serial.close() except Exception: pass self.status_changed.emit(SerialState(self._port, self._baudrate, False)) @Slot(str) def write_text(self, text: str) -> None: if not text.endswith("\r\n"): text = text + "\r\n" self._write_queue.put((text.encode("utf-8", errors="ignore"), True)) @Slot(bytes) def write_bytes(self, payload: bytes) -> None: self._write_queue.put((payload, False)) def set_dtr(self, state: bool) -> None: if self._serial: self._serial.setDTR(state) def set_rts(self, state: bool) -> None: if self._serial: self._serial.setRTS(state) @Slot() def stop(self) -> None: self._running.clear() class _SyntheticWorker(QThread): line_received = Signal(str, str) status_changed = Signal(object) def __init__(self, rate_hz: float = 50.0, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._rate_hz = rate_hz self._running = threading.Event() self._counter = 0 def run(self) -> None: self._running.set() self.status_changed.emit(SerialState("synthetic", config.DEFAULT_BAUD, True)) try: while self._running.is_set(): self._counter += 1 self.line_received.emit(f"SYN {self._counter:06d}", "rx") time.sleep(1.0 / self._rate_hz) finally: self.status_changed.emit(SerialState("synthetic", config.DEFAULT_BAUD, False)) @Slot(str) def write_text(self, text: str) -> None: # Echo back as RX to simulate loopback. clean = text.rstrip("\r\n") self.line_received.emit(clean, "tx") self.line_received.emit(f"ECHO: {clean}", "rx") @Slot(bytes) def write_bytes(self, payload: bytes) -> None: try: clean = payload.decode(errors="ignore") except Exception: clean = repr(payload) self.line_received.emit(clean, "tx") self.line_received.emit(f"ECHO: {clean}", "rx") @Slot() def stop(self) -> None: self._running.clear() class SerialService(QObject): line_received = Signal(str, str) status_changed = Signal(object) def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._worker: Optional[QThread] = None def open(self, port: str, baudrate: int) -> None: self.close() if port.lower() == "synthetic": worker: QThread = _SyntheticWorker() else: worker = _SerialWorker(port, baudrate) worker.line_received.connect(self.line_received) worker.status_changed.connect(self.status_changed) self._worker = worker worker.start() def close(self) -> None: if self._worker: try: self._worker.stop() # type: ignore[attr-defined] self._worker.wait(1000) except Exception: pass self._worker = None def write(self, text: str) -> None: if self._worker: self._worker.write_text(text) # type: ignore[attr-defined] def write_raw(self, data: bytes | str) -> None: if isinstance(data, str): data = data.encode("utf-8", errors="ignore") if self._worker: self._worker.write_bytes(data) # type: ignore[attr-defined] def set_dtr(self, state: bool) -> None: if self._worker and hasattr(self._worker, "set_dtr"): self._worker.set_dtr(state) def set_rts(self, state: bool) -> None: if self._worker and hasattr(self._worker, "set_rts"): self._worker.set_rts(state) def is_connected(self) -> bool: return bool(self._worker and self._worker.isRunning())