Danil Iarosh add8aff0af feat: release version 2.0.0
- Добавлена обратная совместимость с прошивками, содержащими маркер no_auth.

- Повторная авторизация после команды 04 и успешного обновления прошивки приведена к требуемому порядку.

- В терминал команд добавлена ручная кнопка авторизации.

- Реализовано OTA-обновление приложения с загрузкой платформенного артефакта, безопасной заменой и перезапуском.

- Проверка обновлений учитывает только более новые версии по схеме major, minor и patch.

- Обновлены имена Windows и Linux сборок, документация и тестовое покрытие.
2026-09-16 10:01:35 +03:00

436 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)