Improve controller startup handshake and builds
- Добавлен стартовый handshake: ожидание строки версии контроллера, пауза 2000 мс и подтверждение готовности только по READY!. - Исправлена работа COM-буфера и аппаратного сброса через RTS, добавлены модульные тесты протокола. - Добавлена нативная сборка для Debian 13 x86_64 и обновлена документация. - Улучшена очистка Windows-сборки и кроссплатформенная обработка devnull.
This commit is contained in:
parent
914d8defe3
commit
5513eb8a56
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,6 +14,9 @@ env/
|
||||
# ── PyInstaller build ─────────────────────────────────
|
||||
build/
|
||||
dist/
|
||||
build-linux/
|
||||
dist-linux/
|
||||
.venv-linux/
|
||||
*.manifest
|
||||
*.spec.bak
|
||||
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@ -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
|
||||
|
||||
- Перед тем как приступать к выполнению любой задачи (кроме тривиальных исправлений опечаток или ответов на вопросы), агент обязан составить подробный план реализации изменений.
|
||||
|
||||
18
BUILD_EXE.md
18
BUILD_EXE.md
@ -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`)
|
||||
|
||||
@ -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).
|
||||
|
||||
---
|
||||
|
||||
## Интерфейс приложения
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
52
build_linux.sh
Normal 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"
|
||||
5
main.py
5
main.py
@ -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
179
tests/test_auth_service.py
Normal 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
82
tests/test_com_service.py
Normal 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()
|
||||
@ -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` и любые другие строки сами по себе не
|
||||
означают успешного завершения авторизации.
|
||||
|
||||
# Команды
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user