- Добавлена обратная совместимость с прошивками, содержащими маркер no_auth. - Повторная авторизация после команды 04 и успешного обновления прошивки приведена к требуемому порядку. - В терминал команд добавлена ручная кнопка авторизации. - Реализовано OTA-обновление приложения с загрузкой платформенного артефакта, безопасной заменой и перезапуском. - Проверка обновлений учитывает только более новые версии по схеме major, minor и patch. - Обновлены имена Windows и Linux сборок, документация и тестовое покрытие.
436 lines
17 KiB
Python
436 lines
17 KiB
Python
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)
|