from __future__ import annotations import threading import time as _time from PySide6.QtCore import QObject, Signal from serial import ( EIGHTBITS, FIVEBITS, PARITY_EVEN, PARITY_MARK, PARITY_NONE, PARITY_ODD, PARITY_SPACE, SEVENBITS, SIXBITS, STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO, Serial, ) from serial.tools import list_ports from ..serial_config import SerialParams BYTESIZE_MAP = {"5": FIVEBITS, "6": SIXBITS, "7": SEVENBITS, "8": EIGHTBITS} STOPBITS_MAP = {"1": STOPBITS_ONE, "1.5": STOPBITS_ONE_POINT_FIVE, "2": STOPBITS_TWO} PARITY_MAP = { "None": PARITY_NONE, "Even": PARITY_EVEN, "Odd": PARITY_ODD, "Mark": PARITY_MARK, "Space": PARITY_SPACE, } PARITY_SHORT_MAP = {"None": "N", "Even": "E", "Odd": "O", "Mark": "M", "Space": "S"} STARTUP_RESET_PULSE_SECONDS = 0.12 def list_available_ports() -> list[str]: return [item.device for item in list_ports.comports()] def params_from_ui( *, baudrate: str, data_bits: str, stop_bits: str, parity: str, flow_control: str, ) -> SerialParams: return SerialParams( baudrate=int(baudrate), bytesize=BYTESIZE_MAP[data_bits], stopbits=STOPBITS_MAP[stop_bits], parity=PARITY_MAP[parity], xonxoff=flow_control == "XON/XOFF", rtscts=flow_control == "RTS/CTS", ) class ComConnection(QObject): """Обёртка над pyserial с единым фоновым потоком чтения порта. Фоновый reader-поток постоянно вычитывает строки из serial и рассылает события подписчикам (line/input/adc/info). Когда вызывается send_command() — reader приостанавливается (через lock), чтобы последующий read_response() получил именно ответ на команду. После read_response() (или серии вызовов) нужно вызвать release(), чтобы reader возобновился. Для удобства send_command() автоматически освобождает предыдущую блокировку перед захватом новой. """ line_received = Signal(str) # любая входящая строка input_received = Signal(object) # int (маска входов INPUT...) adc_received = Signal(object) # list[str] (ADCv1,v2,v3,v4) info_received = Signal(str) # COINS / HOPPER / PRIZES / SERVICE def __init__(self) -> None: super().__init__() self._serial: Serial | None = None self._io_lock = threading.Lock() self._cmd_owner = False self._drain_stop = threading.Event() self._drain_paused = threading.Event() # установлен = дренаж приостановлен self._drain_thread: threading.Thread | None = None self._read_buf = bytearray() # остаток между вызовами read_response self._event_buf = bytearray() # буфер фонового reader-потока self._last_cmd_time: float = 0.0 # monotonic-метка последней отправки # Параметры последнего подключения (для reconnect) self._last_port: str | None = None self._last_params: SerialParams | None = None self._firmware_version: str | None = None self._auth_required: bool | None = None # ── Properties ──────────────────────────────────────────────────── @property def is_connected(self) -> bool: return self._serial is not None and self._serial.is_open @property def serial(self) -> Serial | None: return self._serial @property def firmware_version(self) -> str | None: return self._firmware_version @property def auth_required(self) -> bool | None: """Требуется ли авторизация по данным текущей прошивки.""" return self._auth_required def set_firmware_version(self, version: str, *, auth_required: bool) -> None: """Сохранить версию и обнаруженный режим авторизации прошивки.""" self._firmware_version = version.strip() self._auth_required = auth_required @property def seconds_since_last_command(self) -> float: """Сколько секунд прошло с последней отправки команды.""" if self._last_cmd_time == 0.0: return float("inf") return _time.monotonic() - self._last_cmd_time # ── Open / Close ────────────────────────────────────────────────── def open( self, *, port: str, params: SerialParams, timeout: float = 0.1, drain_paused: bool = False, reset_device: bool = False, ) -> None: self.close() self._last_port = port self._last_params = params self._serial = Serial( port=port, baudrate=params.baudrate, bytesize=params.bytesize, parity=params.parity, stopbits=params.stopbits, xonxoff=params.xonxoff, rtscts=params.rtscts, timeout=timeout, ) # При старте авторизации reader должен не забрать загрузочный banner. if drain_paused: self._drain_paused.set() else: self._drain_paused.clear() self._read_buf.clear() self._event_buf.clear() if reset_device: self.reset_controller() # Запускаем фоновый дренаж self._drain_stop.clear() self._drain_thread = threading.Thread( target=self._drain_loop, daemon=True, name="serial-drain" ) self._drain_thread.start() def close(self) -> None: # Останавливаем дренаж self._drain_stop.set() # Если lock удерживается командой — освободить if self._cmd_owner: self._cmd_owner = False try: self._io_lock.release() except RuntimeError: pass if self._drain_thread is not None: self._drain_thread.join(timeout=2) self._drain_thread = None if self._serial and self._serial.is_open: self._serial.close() self._serial = None self._read_buf.clear() self._event_buf.clear() self._firmware_version = None self._auth_required = None def reconnect( self, *, drain_paused: bool = False, reset_device: bool = False, ) -> None: """Переподключиться с параметрами последнего open(). Raises: RuntimeError: если параметры подключения неизвестны. serial.SerialException: если порт недоступен. """ if self._last_port is None or self._last_params is None: raise RuntimeError("Нет сохранённых параметров для reconnect") self.open( port=self._last_port, params=self._last_params, drain_paused=drain_paused, reset_device=reset_device, ) def reset_controller(self, pulse_seconds: float = STARTUP_RESET_PULSE_SECONDS) -> None: """Перезагрузить ESP32 импульсом RTS, не закрывая COM-порт.""" if not self.is_connected: raise RuntimeError("Порт не открыт") assert self._serial is not None self._read_buf.clear() self._event_buf.clear() self._serial.reset_input_buffer() self._serial.dtr = False self._serial.rts = True try: _time.sleep(pulse_seconds) finally: self._serial.rts = False # ── Send / Receive ──────────────────────────────────────────────── def send_command(self, cmd: str) -> None: """Отправить текстовую команду (добавляет ``\\r\\n``). Захватывает *io-lock*, приостанавливая фоновый дренаж, и очищает входной буфер. Блокировка удерживается до тех пор, пока не будет вызван release() (или следующий send_command). """ if not self.is_connected: raise RuntimeError("Порт не открыт") assert self._serial is not None # Если предыдущая команда не вызвала release — освободим сами if self._cmd_owner: self._cmd_owner = False try: self._io_lock.release() except RuntimeError: pass self._io_lock.acquire() self._cmd_owner = True self._read_buf.clear() self._serial.reset_input_buffer() self._serial.write((cmd + "\r\n").encode("ascii")) self._last_cmd_time = _time.monotonic() def write_raw(self, cmd: str) -> None: """Отправить команду без перезахвата lock и без очистки буфера. Используется внутри многошагового протокола (handshake), когда lock уже удерживается предыдущим send_command(). """ if not self.is_connected: raise RuntimeError("Порт не открыт") assert self._serial is not None self._serial.write((cmd + "\r\n").encode("ascii")) self._last_cmd_time = _time.monotonic() def send_async(self, cmd: str) -> None: """Отправить команду, не удерживая lock после записи. Используется UI-обработчиками (кнопки/диалоги), когда не нужно сразу читать синхронный ответ через read_response(). """ if not self.is_connected: raise RuntimeError("Порт не открыт") assert self._serial is not None acquired = self._io_lock.acquire(timeout=0.5) if not acquired: raise TimeoutError("Порт занят другой операцией") try: self._serial.write((cmd + "\r\n").encode("ascii")) self._last_cmd_time = _time.monotonic() finally: self._io_lock.release() def read_response(self, timeout: float | None = None) -> str | None: """Прочитать одну строку ответа. Возвращает None при таймауте. Читает побайтово через ``read(1)`` до ``\\r`` или ``\\n``. Остаток данных сохраняется в ``_read_buf``. """ import time if not self.is_connected: return None assert self._serial is not None effective_timeout = timeout if timeout is not None else (self._serial.timeout or 0.1) deadline = time.monotonic() + effective_timeout buf = self._read_buf # Сначала проверим остаток от предыдущего вызова line = self._extract_line(buf) if line is not None: return line # Не менять Serial.timeout в цикле: в Windows setter каждый раз # перенастраивает драйвер COM и падает на длинном загрузочном логе. while time.monotonic() < deadline: available = self._serial.in_waiting chunk = self._serial.read(available or 1) if not chunk: continue buf.extend(chunk) line = self._extract_line(buf) if line is not None: return line # Таймаут — вернём что накопилось if buf: text = buf.decode("ascii", errors="replace").strip() buf.clear() if text: return text return None @staticmethod def _extract_line(buf: bytearray) -> str | None: """Извлечь первую полную строку из буфера (до \\r или \\n). Если строка найдена — удаляет её из buf и возвращает. Иначе возвращает None, buf остаётся без изменений. """ for i, b in enumerate(buf): if b in (0x0D, 0x0A): raw = buf[:i] # Пропускаем сам разделитель и возможный парный (\r\n или \n\r) end = i + 1 if end < len(buf) and buf[end] in (0x0D, 0x0A) and buf[end] != b: end += 1 del buf[:end] line = raw.decode("ascii", errors="replace").strip() if line: return line # Пустая строка — ищем дальше (рекурсивно по остатку) return ComConnection._extract_line(buf) return None def release(self) -> None: """Освободить io-lock, чтобы фоновый дренаж возобновил работу.""" if self._cmd_owner: self._cmd_owner = False try: self._io_lock.release() except RuntimeError: pass def pause_drain(self) -> None: """Приостановить фоновый дренаж (например, на время теста ввода).""" self._drain_paused.set() def resume_drain(self) -> None: """Возобновить фоновый дренаж.""" self._drain_paused.clear() # ── Фоновый дренаж ──────────────────────────────────────────────── def _dispatch_line(self, line: str) -> None: """Разобрать строку и разослать события подписчикам.""" self.line_received.emit(line) upper = line.upper() if upper.startswith("INPUT"): payload = line[5:].strip() if payload: try: self.input_received.emit(int(payload)) except ValueError: pass return if upper.startswith("ADC"): parts = line[3:].split(",") self.adc_received.emit(parts) return for prefix in ("COINS", "HOPPER", "PRIZES", "SERVICE"): if upper.startswith(prefix): self.info_received.emit(line) return def _drain_loop(self) -> None: """Постоянно вычитывает входящие данные и публикует строки. Работает, пока не установлен _drain_stop. Захватывает io-lock на короткое время; если lock занят командой — ждёт и не мешает. """ while not self._drain_stop.is_set(): # Если дренаж приостановлен — просто ждём if self._drain_paused.is_set(): self._drain_stop.wait(0.05) continue # Пробуем захватить lock; если занят командой — ждём 50 мс acquired = self._io_lock.acquire(timeout=0.05) if not acquired: continue try: if self._serial and self._serial.is_open: old_timeout = self._serial.timeout try: avail = self._serial.in_waiting if avail > 0: chunk = self._serial.read(avail) else: self._serial.timeout = 0.02 chunk = self._serial.read(1) if chunk: self._event_buf.extend(chunk) while True: line = self._extract_line(self._event_buf) if line is None: break self._dispatch_line(line) finally: try: self._serial.timeout = old_timeout except Exception: pass except Exception: pass finally: self._io_lock.release() # Пауза между итерациями self._drain_stop.wait(0.005)