Improve controller startup handshake and builds

- Добавлен стартовый handshake: ожидание строки версии контроллера, пауза 2000 мс и подтверждение готовности только по READY!.

- Исправлена работа COM-буфера и аппаратного сброса через RTS, добавлены модульные тесты протокола.

- Добавлена нативная сборка для Debian 13 x86_64 и обновлена документация.

- Улучшена очистка Windows-сборки и кроссплатформенная обработка devnull.
This commit is contained in:
Данил Ярош 2026-09-11 15:01:45 +03:00
parent 914d8defe3
commit 5513eb8a56
13 changed files with 701 additions and 177 deletions

3
.gitignore vendored
View File

@ -14,6 +14,9 @@ env/
# ── PyInstaller build ─────────────────────────────────
build/
dist/
build-linux/
dist-linux/
.venv-linux/
*.manifest
*.spec.bak

View File

@ -12,15 +12,34 @@
## MCP Tool Preference
- При подходящих сценариях агенту следует по максимуму отдавать приоритет MCP-серверам `claude-core`, `claude-explorer` и `claude-rag` вместо встроенных инструментов.
- При подходящих сценариях агенту следует по максимуму отдавать приоритет MCP-серверам `claude-core` и `claude-explorer` вместо встроенных инструментов.
- `claude-core` следует предпочитать для чтения и редактирования файлов, поиска по проекту, работы с задачами и базовых shell-операций, если его возможностей достаточно.
- `claude-explorer` следует предпочитать для изучения архитектуры, исходников, команд и внутренних инструментов Claude Code.
- `claude-rag` следует предпочитать для семантического поиска по проекту, анализа больших объёмов кода и ситуаций, где локальный индекс даёт преимущество над обычным текстовым поиском.
- Память о проекте, его договорённостях, архитектурных решениях, важных гайдах, кодовой базе и ранее выявленном контексте следует по возможности сохранять и повторно использовать через `claude-rag`.
- Кодовую базу проекта также следует по возможности индексировать и использовать через `claude-rag`, чтобы агент мог опираться на локальную семантическую память о структуре, модулях, связях и ранее изученных участках кода.
- При повторяющихся задачах, исследовании крупных подсистем, возвращении к старым решениям, поиске проектного контекста и анализе существующей кодовой базы агенту следует сначала опираться на `claude-rag`, если для этого подходит локальный индекс.
- Если MCP-инструмент хуже подходит для конкретной задачи, недоступен или создаёт лишние ограничения, агент может использовать встроенные инструменты как запасной вариант.
## Project Memory
- **ОБЯЗАТЕЛЬНОЕ ПРАВИЛО**: MCP-сервер `agentmemory` является постоянной долговременной памятью проекта и должен использоваться непрерывно во всех видах работы: при анализе, планировании, программировании, исправлении ошибок, рефакторинге, тестировании, изменении ресурсов и документации.
- Канонический идентификатор проекта во всех вызовах `agentmemory`: `plate-config-app`. Нельзя создавать отдельные области памяти, используя вместо него путь к каталогу, имя приложения или название подсистемы.
- Перед любой задачей первым действием с инструментами необходимо вызвать `memory_recall` или `memory_smart_search` и восстановить относящиеся к задаче требования, архитектуру, прошлые решения, известные ошибки и сведения о затрагиваемых файлах. Перед изменением незнакомой подсистемы либо при обнаружении противоречия поиск в памяти нужно выполнить повторно.
- `agentmemory` следует использовать не только для решений и ошибок. Сохранять необходимо любую новую полезную и повторно используемую информацию о проекте, в том числе:
- архитектуру, зависимости, границы подсистем, потоки данных, состояния, контракты и принятые решения;
- актуальную структуру каталогов, файлов, ресурсов и модулей, их назначение, точки входа и связи;
- устройство кодовой базы: ключевые классы, функции, сигналы, конфигурационные схемы, протоколы, форматы данных, инварианты и тестовую инфраструктуру;
- требования пользователя, ограничения, запреты, договорённости, настройки, внешние интеграции и сведения о соседних проектах;
- содержание и изменения документации, инструкций агентов, дорожной карты, планов реализации и справочных материалов;
- обнаруженные дефекты, причины, способы исправления, опасные места, технический долг и ещё не закрытые вопросы;
- созданные, изменённые, перемещённые и удалённые файлы, затронутые символы и краткое описание фактического изменения;
- команды сборки и тестирования, результаты проверок, особенности среды и подтверждённое состояние реализации.
- Сохранять контекст нужно не только в конце большой задачи, но и после каждого содержательного открытия, архитектурного решения или законченного блока изменений, если эта информация может понадобиться после смены сессии либо другому агенту. Перед финальным ответом пользователю необходимо выполнить итоговый `memory_save`.
- Записи следует делать атомарными и разделять по смыслу и типу (`architecture`, `pattern`, `bug`, `preference`, `fact`). В каждой записи указывать `project: plate-config-app`, релевантные пути в `files`, понятные `concepts` и подробное описание на русском языке. Для кодовой базы сохранять назначение и поведение файлов и символов, а не бесконтрольно дублировать полный текст исходников.
- Память дополняет, но не заменяет репозиторий. Если данные в `agentmemory` расходятся с текущими файлами, конфигурацией или результатами запуска, источником истины считается проверенное текущее состояние проекта; устаревшую запись необходимо исправить либо сохранить новую запись, явно описывающую расхождение и актуальное состояние.
- Не сохранять в память случайный шум, огромные необработанные логи и секреты, отсутствующие в репозитории или явно не разрешённые пользователем. Любые значимые выводы из таких данных сохранять в кратком структурированном виде.
- Если `agentmemory` временно недоступен, необходимо сообщить об этом в рабочем комментарии, продолжить безопасную локальную работу и повторить сохранение при первой возможности. Недоступность памяти нельзя молча игнорировать.
- Если задача передаётся другому агенту, в поручении требуется обязать его сначала восстановить контекст через `agentmemory`, а по завершении сохранить открытия, изменения, проверки и незакрытые вопросы туда же.
- Для долговременной памяти, восстановления контекста и передачи знаний между агентами разрешён только MCP-сервер `agentmemory`. Не использовать параллельные memory-хранилища других MCP-серверов.
- Для фактической работы с репозиторием, приложением, сборкой и тестами использовать подходящие инструменты; это не заменяет обязательную синхронизацию результатов с `agentmemory`.
## Planning Before Task Execution
- Перед тем как приступать к выполнению любой задачи (кроме тривиальных исправлений опечаток или ответов на вопросы), агент обязан составить подробный план реализации изменений.

View File

@ -1,5 +1,23 @@
# Сборка EXE
## Linux (Debian 13 x86_64)
Для Linux используется нативная сборка: Windows-версия PyInstaller не может
создать рабочий Linux-бинарник. В Debian 13 установите Python и средства
архивации, затем запустите скрипт из корня проекта:
```bash
sudo apt-get update
sudo apt-get install -y python3 python3-venv python3-pip zip libglib2.0-0t64 libgl1 libegl1 libfontconfig1 libxkbcommon0 libfreetype6 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 libxcb-shape0 libxcb-xkb1 libxcb-util1 libxkbcommon-x11-0
chmod +x build_linux.sh
./build_linux.sh
```
Результат: `dist-linux/plate_config_app-debian13-x86_64.zip`.
Архив содержит исполняемый файл `plate_config_app` и папки конфигураций рядом
с ним. Для работы с USB-COM пользователь Debian должен состоять в группе
`dialout`.
## Предварительные требования
- Python ≥ 3.11 (виртуальное окружение `.venv`)

View File

@ -23,6 +23,7 @@
## Системные требования
- Windows 10 / 11
- Debian 13 x86_64 (для Linux-сборки и запуска Linux-пакета)
- COM-порт (физический или виртуальный), к которому подключена плата
Для запуска из собранного EXE дополнительных зависимостей не требуется.
@ -43,6 +44,9 @@
См. [DEVELOPMENT.md](DEVELOPMENT.md).
Для Debian 13 x86_64 используйте [build_linux.sh](build_linux.sh); подробности
сборки приведены в [BUILD_EXE.md](BUILD_EXE.md).
---
## Интерфейс приложения

View File

@ -1,17 +1,23 @@
"""AES-256 двойное рукопожатие (авторизация) с платой периферийного контроллера.
"""Запуск и AES-256 авторизация платы периферийного контроллера.
Алгоритм (из документации «Система команд платы v2.x»):
1. Компьютер → плата: 07<32 hex> (16 случайных байт)
2. Плата → компьютер: CHAL<32 hex> (встречный запрос, 16 байт)
3. Компьютер → плата: 08<32 hex> (зашифрованный ответ на CHAL)
4. Плата → компьютер: RAES<32 hex> (ответ на запрос из шага 1)
Стартовая последовательность:
1. Плата → компьютер: periphery controller ver_<версия и комментарий>
2. Компьютер ждёт 2000 мс.
3. Компьютер → плата: 07<32 hex> (16 случайных байт)
4. Плата → компьютер: CHAL<32 hex> (встречный запрос, 16 байт)
5. Компьютер → плата: 08<32 hex> (зашифрованный ответ на CHAL)
6. Плата → компьютер: RAES<32 hex> (ответ на запрос из шага 3)
7. Плата → компьютер: READY!
Компьютер проверяет RAES — расшифровывает и сравнивает с исходными 16 байт.
Авторизация считается завершённой только после точной строки ``READY!``.
"""
from __future__ import annotations
import hmac
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
@ -19,13 +25,20 @@ from Crypto.Cipher import AES
from .com_service import ComConnection
# ── Фиксированный AES-256 ключ (32 байта) ──────────────────────────────
_AES_KEY = bytes((
STARTUP_BANNER_PREFIX = "periphery controller ver_"
STARTUP_DELAY_SECONDS = 2.0
STARTUP_BANNER_TIMEOUT_SECONDS = 20.0
READY_TIMEOUT_SECONDS = 5.0
# Ключ хранится в том же обфусцированном виде, что и в рабочем клиенте.
_AES_KEY_MASK = 0x37
_AES_OBFUSCATED_KEY = bytes((
0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70,
0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8,
0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24,
0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37,
))
_AES_KEY = bytes(value ^ _AES_KEY_MASK for value in _AES_OBFUSCATED_KEY)
class AuthStatus(Enum):
@ -40,6 +53,7 @@ class AuthStatus(Enum):
class AuthResult:
status: AuthStatus
detail: str = ""
startup_ready: bool = False
def _aes_encrypt(plaintext: bytes, key: bytes = _AES_KEY) -> bytes:
@ -53,14 +67,43 @@ def _aes_decrypt(ciphertext: bytes, key: bytes = _AES_KEY) -> bytes:
return cipher.decrypt(ciphertext)
def _read_until(
conn: ComConnection,
predicate: Callable[[str], bool],
timeout: float,
) -> str | None:
"""Читать полные строки до совпадения или истечения общего таймаута."""
deadline = time.monotonic() + timeout
while conn.is_connected:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
line = conn.read_response(timeout=remaining)
if line is None:
return None
if predicate(line.strip()):
return line.strip()
return None
def wait_for_startup_banner(
conn: ComConnection,
timeout: float = STARTUP_BANNER_TIMEOUT_SECONDS,
) -> str | None:
"""Дождаться ``periphery controller ver_<...>`` с непустым суффиксом."""
def is_banner(line: str) -> bool:
if not line.startswith(STARTUP_BANNER_PREFIX):
return False
return bool(line[len(STARTUP_BANNER_PREFIX):].strip())
return _read_until(conn, is_banner, timeout)
def check_status(conn: ComConnection, timeout: float = 3.0) -> AuthStatus:
"""Отправить команду 99 и проанализировать ответ.
* ``READY`` в ответе → плата авторизована и готова (``OK``).
* Любой другой *непустой* ответ → плата жива, авторизация
не требуется или уже пройдена (``OK``). Это покрывает
платы без системы авторизации.
* Полный таймаут (ни одного ответа) → ``NEED_AUTH``.
Только точная строка ``READY!`` означает готовность. Любой другой
ответ или таймаут означает, что авторизация ещё не подтверждена.
"""
if not conn.is_connected:
return AuthStatus.DISCONNECTED
@ -70,26 +113,17 @@ def check_status(conn: ComConnection, timeout: float = 3.0) -> AuthStatus:
return AuthStatus.DISCONNECTED
try:
import time
deadline = time.monotonic() + timeout
got_any_response = False
while time.monotonic() < deadline:
remaining = max(deadline - time.monotonic(), 0.05)
resp = conn.read_response(timeout=min(remaining, 0.5))
if resp is not None:
got_any_response = True
if "READY" in resp.upper():
return AuthStatus.OK
# Не прерываемся на None — ждём до deadline
# Если хоть один ответ пришёл — плата жива (без авторизации)
if got_any_response:
return AuthStatus.OK
return AuthStatus.NEED_AUTH
ready = _read_until(conn, lambda line: line == "READY!", timeout)
return AuthStatus.OK if ready is not None else AuthStatus.NEED_AUTH
finally:
conn.release()
def perform_handshake(conn: ComConnection, timeout: float = 2.0) -> AuthResult:
def perform_handshake(
conn: ComConnection,
timeout: float = 2.0,
ready_timeout: float = READY_TIMEOUT_SECONDS,
) -> AuthResult:
"""Выполнить полный цикл двойного рукопожатия.
Returns:
@ -108,61 +142,99 @@ def perform_handshake(conn: ComConnection, timeout: float = 2.0) -> AuthResult:
conn.release()
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 07: {exc}")
# ── Шаг 2: ждём CHAL<32 hex> от контроллера ──
resp = conn.read_response(timeout=timeout)
if resp is None:
conn.release()
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания CHAL от контроллера")
try:
# ── Шаг 2: ждём CHAL<32 hex> от контроллера ──
resp = _read_until(conn, lambda line: line.upper().startswith("CHAL"), timeout)
if resp is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания CHAL от контроллера")
# Парсим CHAL — допускаем разделители (пробел, двоеточие)
if not resp.upper().startswith("CHAL"):
conn.release()
return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо CHAL: {resp}")
# Парсим CHAL — допускаем разделители (пробел, двоеточие)
board_challenge_hex = resp[4:].strip().lstrip(":").strip()
if len(board_challenge_hex) != 32:
return AuthResult(
AuthStatus.FAILED,
f"Некорректная длина CHAL: {board_challenge_hex}",
)
# Извлекаем hex-часть, убирая возможные разделители
board_challenge_hex = resp[4:].strip().lstrip(":").strip()
try:
board_challenge = bytes.fromhex(board_challenge_hex)
except ValueError:
return AuthResult(
AuthStatus.FAILED,
f"CHAL содержит не-hex символы: {board_challenge_hex}",
)
if len(board_challenge_hex) != 32:
# ── Шаг 3: шифруем board_challenge AES-256 и отправляем 08<hex> ──
encrypted_response = _aes_encrypt(board_challenge)
cmd_08 = "08" + encrypted_response.hex().upper()
try:
conn.write_raw(cmd_08)
except Exception as exc:
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 08: {exc}")
# ── Шаг 4: проверяем доказательство контроллера RAES ──
raes = _read_until(conn, lambda line: line.upper().startswith("RAES"), timeout)
if raes is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания RAES от контроллера")
controller_proof_hex = raes[4:].strip().lstrip(":").strip()
try:
controller_proof = bytes.fromhex(controller_proof_hex)
except ValueError:
return AuthResult(AuthStatus.FAILED, "RAES содержит некорректные hex-данные")
expected_proof = _aes_encrypt(our_challenge)
if len(controller_proof) != 16 or not hmac.compare_digest(
controller_proof,
expected_proof,
):
return AuthResult(AuthStatus.FAILED, "Проверка RAES контроллера не пройдена")
# AUTH_PC_OK и другие промежуточные строки игнорируются. Успех —
# только точная отдельная строка READY! после RAES.
ready = _read_until(conn, lambda line: line == "READY!", ready_timeout)
if ready is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания READY! от контроллера")
return AuthResult(AuthStatus.OK, "Авторизация подтверждена строкой READY!")
finally:
conn.release()
return AuthResult(AuthStatus.FAILED, f"Некорректная длина CHAL: {board_challenge_hex}")
def perform_startup_handshake(
conn: ComConnection,
*,
banner_timeout: float = STARTUP_BANNER_TIMEOUT_SECONDS,
startup_delay: float = STARTUP_DELAY_SECONDS,
handshake_timeout: float = 2.0,
ready_timeout: float = READY_TIMEOUT_SECONDS,
sleep_fn: Callable[[float], None] = time.sleep,
) -> AuthResult:
"""Дождаться загрузки платы, выдержать паузу и выполнить handshake."""
if not conn.is_connected:
return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт")
try:
board_challenge = bytes.fromhex(board_challenge_hex)
except ValueError:
conn.release()
return AuthResult(AuthStatus.FAILED, f"CHAL содержит не-hex символы: {board_challenge_hex}")
# ── Шаг 3: шифруем board_challenge AES-256 и отправляем 08<hex> ──
encrypted_response = _aes_encrypt(board_challenge)
cmd_08 = "08" + encrypted_response.hex().upper()
try:
conn.write_raw(cmd_08)
banner = wait_for_startup_banner(conn, timeout=banner_timeout)
except Exception as exc:
conn.release()
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 08: {exc}")
return AuthResult(AuthStatus.FAILED, f"Ошибка ожидания строки запуска: {exc}")
# ── Шаг 4: ждём RAES<32 hex> — зашифрованный ответ на наш запрос ──
resp2 = conn.read_response(timeout=timeout)
if resp2 is None:
conn.release()
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания RAES от контроллера")
if banner is None:
return AuthResult(
AuthStatus.TIMEOUT,
f"Таймаут ожидания {STARTUP_BANNER_PREFIX}<версия>",
)
upper2 = resp2.upper()
if upper2.startswith("RAES"):
# RAES получен — плата ответила на наш challenge.
# Верификацию RAES пропускаем (плата уже приняла наш 08).
pass
elif "READY" in upper2 or "AUTH_CNT_OK" in upper2:
conn.release()
return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({resp2})")
else:
conn.release()
return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо RAES: {resp2}")
sleep_fn(startup_delay)
if not conn.is_connected:
return AuthResult(AuthStatus.DISCONNECTED, "Порт закрыт до начала авторизации")
# Ждём AUTH_CNT_OK / READY после RAES
ready_resp = conn.read_response(timeout=timeout)
conn.release()
if ready_resp:
return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({ready_resp})")
return AuthResult(AuthStatus.OK, "Авторизация пройдена")
result = perform_handshake(
conn,
timeout=handshake_timeout,
ready_timeout=ready_timeout,
)
result.startup_ready = True
if result.status == AuthStatus.OK:
result.detail = f"{banner}\n{result.detail}"
return result

View File

@ -33,6 +33,7 @@ PARITY_MAP = {
"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]:
@ -110,7 +111,15 @@ class ComConnection(QObject):
# ── Open / Close ──────────────────────────────────────────────────
def open(self, *, port: str, params: SerialParams, timeout: float = 0.1) -> None:
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
@ -124,9 +133,19 @@ class ComConnection(QObject):
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._event_buf.clear()
self._drain_thread = threading.Thread(
target=self._drain_loop, daemon=True, name="serial-drain"
)
@ -148,9 +167,15 @@ class ComConnection(QObject):
if self._serial and self._serial.is_open:
self._serial.close()
self._serial = None
self._read_buf.clear()
self._event_buf.clear()
def reconnect(self) -> None:
def reconnect(
self,
*,
drain_paused: bool = False,
reset_device: bool = False,
) -> None:
"""Переподключиться с параметрами последнего open().
Raises:
@ -159,7 +184,28 @@ class ComConnection(QObject):
"""
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)
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 ────────────────────────────────────────────────
@ -240,23 +286,17 @@ class ComConnection(QObject):
if line is not None:
return line
old_timeout = self._serial.timeout
try:
while time.monotonic() < deadline:
remaining = max(deadline - time.monotonic(), 0.001)
self._serial.timeout = min(remaining, 0.05)
b = self._serial.read(1)
if not b:
continue
buf.extend(b)
line = self._extract_line(buf)
if line is not None:
return line
finally:
try:
self._serial.timeout = old_timeout
except Exception:
pass
# Не менять 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:

View File

@ -26,7 +26,13 @@ from ..services.com_service import (
list_available_ports,
params_from_ui,
)
from ..services.auth_service import AuthResult, AuthStatus, check_status, perform_handshake
from ..services.auth_service import (
AuthResult,
AuthStatus,
check_status,
perform_handshake,
perform_startup_handshake,
)
from ..services import settings_service
from ..ui.icons import get_icon_color, get_icon_muted, icon
from ..serial_config import SerialParams
@ -115,6 +121,7 @@ class ConnectionTab(QWidget):
self._health_running = False # идёт ли сейчас проверка
self._health_fail_count = 0 # подряд неудачных проверок
self._payment_observers_bound = False
self._startup_ready = False
self._setup_ui()
self._load_ports()
@ -362,6 +369,8 @@ class ConnectionTab(QWidget):
def _start_basic_polling(self) -> None:
"""Запустить подписку на входы и подключить PaymentSection к reader-событиям."""
if self._plate_state != PlateState.READY:
return
self._input_section.start_polling()
if not self._payment_observers_bound:
self._connection.input_received.connect(
@ -393,7 +402,7 @@ class ConnectionTab(QWidget):
for group in self._basic_groups:
group.setVisible(not checked)
# Управление poll-потоком InputTestSection
if not checked:
if not checked and self._plate_state == PlateState.READY:
self._start_basic_polling()
else:
self._input_section.stop_polling()
@ -433,8 +442,14 @@ class ConnectionTab(QWidget):
return
params = self._collect_params()
self._startup_ready = False
try:
self._connection.open(port=port, params=params)
self._connection.open(
port=port,
params=params,
drain_paused=True,
reset_device=True,
)
except Exception as exc:
QMessageBox.critical(self, "Ошибка подключения", f"Не удалось открыть {port}\n{exc}")
return
@ -448,21 +463,14 @@ class ConnectionTab(QWidget):
f"{PARITY_SHORT_MAP[self.parity_combo.currentText()]}-"
f"{self.stop_bits_combo.currentText()})"
)
self._set_status(self._conn_info, PlateState.NEED_AUTH)
self._update_plate_state(PlateState.NEED_AUTH)
tab_index = self._tab_index_getter()
if tab_index >= 0:
self._set_tab_title(tab_index, port)
# Проверяем авторизацию один раз при подключении
self._check_auth_on_connect()
# Запускаем периодический health-check (если включён в настройках)
self._apply_health_settings()
# Если PRO выключен — запускаем опрос входов в inline-секции
if not self._pro_toggle.isChecked():
self._start_basic_polling()
# До стартового banner и READY! никакие другие команды не отправляются.
self._start_startup_handshake()
def _close_this_tab(self) -> None:
index = self._tab_index_getter()
@ -485,12 +493,14 @@ class ConnectionTab(QWidget):
self._button_backlight_section.shutdown()
self._firmware_section.cancel_upload()
self._connection.close()
self._startup_ready = False
# Дождаться завершения всех фоновых потоков
for thread in self._active_threads:
if thread.isRunning():
thread.quit()
thread.wait(1000)
self._active_threads.clear()
self._set_board_actions_enabled(False)
self._update_plate_state(PlateState.DISCONNECTED)
self._reconnect_btn.hide()
@ -501,22 +511,26 @@ class ConnectionTab(QWidget):
# Остановить опрос / health
self._stop_health_timer()
self._input_section.stop_polling()
self._set_board_actions_enabled(False)
port = self._connection.serial.port if self._connection.serial else None
if port is None:
return
params = self._collect_params()
self._connection.close()
self._startup_ready = False
self._update_plate_state(PlateState.DISCONNECTED)
try:
self._connection.open(port=port, params=params)
self._connection.open(
port=port,
params=params,
drain_paused=True,
reset_device=True,
)
except Exception as exc:
QMessageBox.critical(self, "Переподключение", f"Не удалось открыть {port}\n{exc}")
return
self._set_status(self._conn_info, PlateState.NEED_AUTH)
self._check_auth_on_connect()
self._apply_health_settings()
if not self._pro_toggle.isChecked():
self._start_basic_polling()
self._update_plate_state(PlateState.NEED_AUTH)
self._start_startup_handshake()
# ── Открытие диалогов ───────────────────────────────────────────────
@ -674,54 +688,79 @@ class ConnectionTab(QWidget):
else:
self.auth_btn.hide()
def _check_auth_on_connect(self) -> None:
"""Проверить статус авторизации сразу после подключения (в фоновом потоке)."""
self._run_status_check(timeout=3)
def _run_status_check(self, timeout: float = 0.5) -> None:
"""Запуск проверки статуса в фоновом потоке."""
thread = _StatusThread(self._connection, timeout, parent=self)
thread.result_ready.connect(self._on_status_checked)
thread.finished.connect(lambda: self._cleanup_thread(thread))
self._active_threads.append(thread)
thread.start()
def _cleanup_thread(self, thread: QThread) -> None:
"""Убрать завершённый поток из списка."""
if thread in self._active_threads:
self._active_threads.remove(thread)
thread.deleteLater()
def _on_status_checked(self, status: AuthStatus) -> None:
if status == AuthStatus.OK:
self._update_plate_state(PlateState.READY)
elif status == AuthStatus.NEED_AUTH:
self._update_plate_state(PlateState.NEED_AUTH)
else:
self._update_plate_state(PlateState.DISCONNECTED)
def _start_startup_handshake(self) -> None:
"""Дождаться загрузочной строки платы и автоматически авторизоваться."""
self._run_handshake(wait_for_startup=True)
def _set_board_actions_enabled(self, enabled: bool) -> None:
"""Запретить команды UI до подтверждённой строки READY!."""
for group in self._basic_groups:
group.setEnabled(enabled)
for button, _ in self._pro_buttons:
button.setEnabled(enabled)
self._command_input_btn.setEnabled(enabled)
def _do_handshake(self) -> None:
"""Выполнить авторизацию (двойное рукопожатие AES-256) в фоновом потоке."""
self._run_handshake(wait_for_startup=not self._startup_ready)
def _run_handshake(self, *, wait_for_startup: bool) -> None:
if not self._connection.is_connected:
QMessageBox.warning(self, "Авторизация", "Порт не подключён.")
return
self._stop_health_timer()
self._input_section.stop_polling()
self._connection.pause_drain()
self._set_board_actions_enabled(False)
self.auth_btn.setEnabled(False)
self.auth_btn.setText("Авторизация…")
if wait_for_startup:
self.auth_btn.setText("Ожидание платы…")
else:
self.auth_btn.setText("Авторизация…")
thread = _HandshakeThread(self._connection, parent=self)
thread.result_ready.connect(self._on_handshake_done)
thread = _HandshakeThread(
self._connection,
wait_for_startup=wait_for_startup,
parent=self,
)
thread.result_ready.connect(
lambda result, automatic=wait_for_startup: self._on_handshake_done(
result,
automatic=automatic,
)
)
thread.finished.connect(lambda: self._cleanup_thread(thread))
self._active_threads.append(thread)
thread.start()
def _on_handshake_done(self, result: AuthResult) -> None:
def _on_handshake_done(self, result: AuthResult, *, automatic: bool) -> None:
self._connection.resume_drain()
if automatic:
self._startup_ready = result.startup_ready
self.auth_btn.setEnabled(True)
self.auth_btn.setText("Handshake (Авторизация)")
if result.status == AuthStatus.OK:
self._update_plate_state(PlateState.READY)
QMessageBox.information(self, "Авторизация", f"Успешно!\n{result.detail}")
self._set_board_actions_enabled(True)
self._health_fail_count = 0
self._apply_health_settings()
if not self._pro_toggle.isChecked():
self._start_basic_polling()
if not automatic:
QMessageBox.information(self, "Авторизация", f"Успешно!\n{result.detail}")
else:
self._update_plate_state(PlateState.DISCONNECTED)
state = (
PlateState.NEED_AUTH
if self._connection.is_connected
else PlateState.DISCONNECTED
)
self._update_plate_state(state)
QMessageBox.warning(
self,
"Авторизация",
@ -794,13 +833,18 @@ class ConnectionTab(QWidget):
def _on_health_result(self, status: AuthStatus) -> None:
self._health_running = False
if status == AuthStatus.OK:
# Связь жива, плата авторизована (или не требует авторизацию)
# Только READY! подтверждает готовность платы.
self._health_fail_count = 0
if self._plate_state != PlateState.READY:
self._update_plate_state(PlateState.READY)
self._set_board_actions_enabled(True)
if not self._pro_toggle.isChecked():
self._start_basic_polling()
elif status == AuthStatus.NEED_AUTH:
# Связь жива, но нужна авторизация (полный таймаут на 99)
# Связь жива, но READY! не получен.
self._health_fail_count = 0
self._input_section.stop_polling()
self._set_board_actions_enabled(False)
if self._plate_state != PlateState.NEED_AUTH:
self._update_plate_state(PlateState.NEED_AUTH)
else:
@ -808,6 +852,8 @@ class ConnectionTab(QWidget):
self._health_fail_count += 1
if self._health_fail_count >= self._MAX_HEALTH_FAILURES:
self._health_fail_count = 0
self._set_board_actions_enabled(False)
self._startup_ready = False
self._update_plate_state(PlateState.DISCONNECTED)
self._connection.close()
@ -826,25 +872,12 @@ class ConnectionTab(QWidget):
self._health_running = False
if success:
self._health_fail_count = 0
# Порт снова доступен — проверяем авторизацию
self._check_auth_on_connect()
self._startup_ready = False
self._update_plate_state(PlateState.NEED_AUTH)
self._start_startup_handshake()
# Если не удалось — ничего не делаем, следующий тик попробует снова
class _StatusThread(QThread):
"""Фоновый поток для проверки статуса платы."""
result_ready = Signal(AuthStatus)
def __init__(self, connection: ComConnection, timeout: float, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._conn = connection
self._timeout = timeout
def run(self) -> None: # noqa: D401
status = check_status(self._conn, timeout=self._timeout)
self.result_ready.emit(status)
class _HealthCheckThread(QThread):
"""Быстрая проверка связи с платой (команда 99, таймаут 2 с)."""
result_ready = Signal(AuthStatus)
@ -871,21 +904,30 @@ class _ReconnectThread(QThread):
def run(self) -> None: # noqa: D401
try:
self._conn.reconnect()
self._conn.reconnect(drain_paused=True, reset_device=True)
self.result_ready.emit(True)
except Exception:
self.result_ready.emit(False)
class _HandshakeThread(QThread):
"""Фоновый поток для авторизации."""
"""Фоновый поток стартовой последовательности или повторной авторизации."""
result_ready = Signal(object)
def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None:
def __init__(
self,
connection: ComConnection,
*,
wait_for_startup: bool,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._conn = connection
self._wait_for_startup = wait_for_startup
def run(self) -> None: # noqa: D401
from ..services.auth_service import perform_handshake as _handshake
result = _handshake(self._conn, timeout=2.0)
if self._wait_for_startup:
result = perform_startup_handshake(self._conn)
else:
result = perform_handshake(self._conn, timeout=2.0)
self.result_ready.emit(result)

View File

@ -19,11 +19,14 @@ function Clean-Build {
}
}
# __pycache__ в проекте (без .venv)
Get-ChildItem -Path $projectRoot -Directory -Filter "__pycache__" -Recurse |
Get-ChildItem -Path $projectRoot -Directory -Filter "__pycache__" -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notlike "*\.venv\*" } |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
Remove-Item $_.FullName -Recurse -Force
Write-Host " Удалено: $($_.FullName -replace [regex]::Escape($projectRoot), '.')"
if (Test-Path -LiteralPath $_.FullName) {
Remove-Item -LiteralPath $_.FullName -Recurse -Force
Write-Host " Удалено: $($_.FullName -replace [regex]::Escape($projectRoot), '.')"
}
}
Write-Host "Очистка завершена."
}

52
build_linux.sh Normal file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Нативная сборка одного исполняемого файла для Debian x86_64.
set -euo pipefail
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
venv_dir="$project_root/.venv-linux"
build_dir="$project_root/build-linux"
dist_dir="$project_root/dist-linux"
package_name="plate_config_app-debian13-x86_64"
package_dir="$dist_dir/$package_name"
cd "$project_root"
rm -rf "$build_dir" "$dist_dir"
python3 -m venv "$venv_dir"
"$venv_dir/bin/python" -m pip install --upgrade pip
"$venv_dir/bin/python" -m pip install -r requirements.txt -r requirements-build.txt
"$venv_dir/bin/python" -m unittest discover -s tests -v
"$venv_dir/bin/python" -m PyInstaller \
--noconfirm \
--clean \
--workpath "$build_dir" \
--distpath "$dist_dir" \
plate_config_app.spec
mkdir -p "$package_dir"
install -m 0755 "$dist_dir/plate_config_app" "$package_dir/plate_config_app"
for resource_dir in light_modes input_mappings commands; do
cp -a "$resource_dir" "$package_dir/$resource_dir"
done
cat > "$package_dir/README.txt" <<'EOF'
Plate Config App для Debian 13 x86_64
Запуск:
./plate_config_app
Папки light_modes, input_mappings и commands можно изменять без пересборки.
Для доступа к USB-COM добавьте пользователя в группу dialout и войдите в систему заново:
sudo usermod -aG dialout "$USER"
EOF
(
cd "$dist_dir"
zip -qr "$package_name.zip" "$package_name"
sha256sum "$package_name.zip" > "$package_name.zip.sha256"
)
printf 'Готово: %s\n' "$dist_dir/$package_name.zip"
sha256sum "$dist_dir/$package_name.zip"

View File

@ -1,11 +1,12 @@
import os
import sys
# В windowed-сборке (PyInstaller --noconsole) stdout/stderr могут быть None.
# Подменяем их на devnull, чтобы print() и faulthandler не падали.
if sys.stderr is None:
sys.stderr = open("NUL", "w") # noqa: SIM115
sys.stderr = open(os.devnull, "w") # noqa: SIM115
if sys.stdout is None:
sys.stdout = open("NUL", "w") # noqa: SIM115
sys.stdout = open(os.devnull, "w") # noqa: SIM115
import faulthandler

179
tests/test_auth_service.py Normal file
View File

@ -0,0 +1,179 @@
from __future__ import annotations
import unittest
from collections import deque
from unittest.mock import patch
from app.services.auth_service import (
AuthStatus,
_aes_encrypt,
check_status,
perform_handshake,
perform_startup_handshake,
wait_for_startup_banner,
)
class FakeConnection:
def __init__(self, responses: list[str | None]) -> None:
self.is_connected = True
self._responses = deque(responses)
self.events: list[str] = []
self.release_count = 0
def read_response(self, timeout: float | None = None) -> str | None:
response = self._responses.popleft() if self._responses else None
self.events.append(f"read:{response}")
return response
def send_command(self, command: str) -> None:
self.events.append(f"send:{command}")
def write_raw(self, command: str) -> None:
self.events.append(f"write:{command}")
def release(self) -> None:
self.release_count += 1
class StartupHandshakeTests(unittest.TestCase):
_HOST_CHALLENGE = bytes.fromhex("00112233445566778899AABBCCDDEEFF")
_CHAL = "CHAL00112233445566778899AABBCCDDEEFF"
_RAES = "RAES40E9A18C22C2CFCD764666C90B358F9D"
def _perform_handshake(
self,
conn: FakeConnection,
*,
timeout: float = 0.1,
ready_timeout: float = 0.1,
):
with patch(
"app.services.auth_service.os.urandom",
return_value=self._HOST_CHALLENGE,
):
return perform_handshake(
conn,
timeout=timeout,
ready_timeout=ready_timeout,
)
def test_waits_for_banner_and_two_seconds_before_sending_auth(self) -> None:
conn = FakeConnection([
"boot log",
"periphery controller ver_2.3.6 release",
self._CHAL,
self._RAES,
"AUTH_PC_OK",
"READY!",
])
sleep_calls: list[float] = []
def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
conn.events.append(f"sleep:{seconds}")
with patch(
"app.services.auth_service.os.urandom",
return_value=self._HOST_CHALLENGE,
):
result = perform_startup_handshake(
conn,
startup_delay=2.0,
sleep_fn=fake_sleep,
)
self.assertEqual(AuthStatus.OK, result.status)
self.assertTrue(result.startup_ready)
self.assertEqual([2.0], sleep_calls)
sleep_index = conn.events.index("sleep:2.0")
send_index = next(
index for index, event in enumerate(conn.events) if event.startswith("send:07")
)
self.assertLess(sleep_index, send_index)
def test_banner_requires_non_empty_version_suffix(self) -> None:
conn = FakeConnection(["periphery controller ver_", None])
banner = wait_for_startup_banner(conn, timeout=0.1)
self.assertIsNone(banner)
self.assertFalse(any(event.startswith("send:") for event in conn.events))
def test_startup_timeout_never_sends_auth_command(self) -> None:
conn = FakeConnection(["unrelated boot log", None])
result = perform_startup_handshake(
conn,
banner_timeout=0.1,
startup_delay=2.0,
sleep_fn=lambda _seconds: self.fail("Пауза не должна начинаться без banner"),
)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertFalse(result.startup_ready)
self.assertFalse(any(event.startswith("send:") for event in conn.events))
def test_raes_does_not_complete_handshake(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertIn("READY!", result.detail)
def test_auth_pc_ok_does_not_complete_handshake(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, "AUTH_PC_OK", None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertIn("READY!", result.detail)
def test_ready_without_exclamation_mark_is_rejected(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, "READY", None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
def test_exact_ready_bang_completes_handshake(self) -> None:
conn = FakeConnection([
self._CHAL,
self._RAES,
"AUTH_PC_OK",
" READY! ",
])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.OK, result.status)
self.assertEqual(1, conn.release_count)
def test_effective_aes_key_matches_controller_profile(self) -> None:
encrypted = _aes_encrypt(self._HOST_CHALLENGE)
self.assertEqual("40E9A18C22C2CFCD764666C90B358F9D", encrypted.hex().upper())
def test_invalid_raes_fails_controller_verification(self) -> None:
conn = FakeConnection([
self._CHAL,
"RAES00000000000000000000000000000000",
"READY!",
])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.FAILED, result.status)
self.assertIn("RAES", result.detail)
def test_status_requires_exact_ready_bang(self) -> None:
ready_conn = FakeConnection(["READY!"])
not_ready_conn = FakeConnection(["READY", None])
self.assertEqual(AuthStatus.OK, check_status(ready_conn, timeout=0.1))
self.assertEqual(AuthStatus.NEED_AUTH, check_status(not_ready_conn, timeout=0.1))
if __name__ == "__main__":
unittest.main()

82
tests/test_com_service.py Normal file
View File

@ -0,0 +1,82 @@
from __future__ import annotations
import unittest
from collections import deque
from app.services.com_service import ComConnection
class FakeSerial:
def __init__(self, chunks: list[bytes] | None = None) -> None:
self.is_open = True
self._timeout = 0.1
self._chunks = deque(chunks or [])
self.events: list[str] = []
self._dtr = True
self._rts = False
@property
def timeout(self) -> float:
return self._timeout
@timeout.setter
def timeout(self, _value: float) -> None:
raise AssertionError("read_response не должен перенастраивать timeout COM-порта")
@property
def in_waiting(self) -> int:
return len(self._chunks[0]) if self._chunks else 0
@property
def dtr(self) -> bool:
return self._dtr
@dtr.setter
def dtr(self, value: bool) -> None:
self._dtr = value
self.events.append(f"dtr:{value}")
@property
def rts(self) -> bool:
return self._rts
@rts.setter
def rts(self, value: bool) -> None:
self._rts = value
self.events.append(f"rts:{value}")
def read(self, _size: int) -> bytes:
return self._chunks.popleft() if self._chunks else b""
def reset_input_buffer(self) -> None:
self.events.append("reset_input_buffer")
class ComConnectionTests(unittest.TestCase):
def test_read_response_keeps_serial_timeout_unchanged(self) -> None:
serial = FakeSerial([b"boot log\r\nperiphery controller ver_2.4.2_MDB\r\n"])
conn = ComConnection()
conn._serial = serial
self.assertEqual("boot log", conn.read_response(timeout=0.2))
self.assertEqual(
"periphery controller ver_2.4.2_MDB",
conn.read_response(timeout=0.2),
)
self.assertEqual(0.1, serial.timeout)
def test_reset_controller_uses_rts_pulse_with_dtr_inactive(self) -> None:
serial = FakeSerial()
conn = ComConnection()
conn._serial = serial
conn.reset_controller(pulse_seconds=0)
self.assertEqual(
["reset_input_buffer", "dtr:False", "rts:True", "rts:False"],
serial.events,
)
if __name__ == "__main__":
unittest.main()

View File

@ -72,6 +72,12 @@ by: Паша Николаевич
После загрузки контроллер не принимает никаких команд, кроме запросов на авторизацию.
После открытия COM-порта программа выполняет штатный аппаратный сброс контроллера
коротким импульсом RTS и затем ожидает отдельную строку
`periphery controller ver_<номер версии и комментарий>`. До её получения программа
не отправляет контроллеру команды. После получения строки программа выдерживает паузу
`2000 мс` и только затем начинает авторизацию.
Запрос – требование зашифровать строку шестнадцатеричных символов с помощью AES256 алгоритма, используя известный ключ.
Компьютер генерирует случайную последовательность из 16 байт и отправляет ее контроллеру на шифрование. Тот ее шифрует и отвечает. Компьютер расшифровывает и проверяет на совпадение.
@ -93,7 +99,10 @@ by: Паша Николаевич
4. **Контроллер ответит на первый запрос компа**:
**RAES\<symbols\> (16 байт или 32 HEX-символа)**
***Пример:*** **RAES***99ED731648AB8C1709CC4C3C8C3BB0E1*
5. При успехе контроллер отправляет `AUTH_PC_OK` и статус. При ошибке - `AUTH_PC_FAIL`.
5. Контроллер может отправить промежуточные ответы `AUTH_PC_OK` и статус.
6. Авторизация считается полностью завершённой только после получения отдельной точной
строки `READY!`. Ответы `RAES`, `AUTH_PC_OK` и любые другие строки сами по себе не
означают успешного завершения авторизации.
# Команды