From 5513eb8a56dec8697dd9666a978f6aeba1fd45fa Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Fri, 11 Sep 2026 15:01:45 +0300 Subject: [PATCH] Improve controller startup handshake and builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен стартовый handshake: ожидание строки версии контроллера, пауза 2000 мс и подтверждение готовности только по READY!. - Исправлена работа COM-буфера и аппаратного сброса через RTS, добавлены модульные тесты протокола. - Добавлена нативная сборка для Debian 13 x86_64 и обновлена документация. - Улучшена очистка Windows-сборки и кроссплатформенная обработка devnull. --- .gitignore | 3 + AGENTS.md | 29 ++++- BUILD_EXE.md | 18 +++ README.md | 4 + app/services/auth_service.py | 228 ++++++++++++++++++++++------------ app/services/com_service.py | 82 ++++++++---- app/widgets/connection_tab.py | 176 ++++++++++++++++---------- build_exe.ps1 | 9 +- build_linux.sh | 52 ++++++++ main.py | 5 +- tests/test_auth_service.py | 179 ++++++++++++++++++++++++++ tests/test_com_service.py | 82 ++++++++++++ Система команд платы v2.x.md | 11 +- 13 files changed, 701 insertions(+), 177 deletions(-) create mode 100644 build_linux.sh create mode 100644 tests/test_auth_service.py create mode 100644 tests/test_com_service.py diff --git a/.gitignore b/.gitignore index 6bccc6b..4589df9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ env/ # ── PyInstaller build ───────────────────────────────── build/ dist/ +build-linux/ +dist-linux/ +.venv-linux/ *.manifest *.spec.bak diff --git a/AGENTS.md b/AGENTS.md index 942ea6b..390221d 100644 --- a/AGENTS.md +++ b/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 - Перед тем как приступать к выполнению любой задачи (кроме тривиальных исправлений опечаток или ответов на вопросы), агент обязан составить подробный план реализации изменений. diff --git a/BUILD_EXE.md b/BUILD_EXE.md index ddac023..badbd57 100644 --- a/BUILD_EXE.md +++ b/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`) diff --git a/README.md b/README.md index bd5de83..88ad296 100644 --- a/README.md +++ b/README.md @@ -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). + --- ## Интерфейс приложения diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 0298106..8a619d9 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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 ── + 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 ── - 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 diff --git a/app/services/com_service.py b/app/services/com_service.py index 3f382e5..3552fdd 100644 --- a/app/services/com_service.py +++ b/app/services/com_service.py @@ -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: diff --git a/app/widgets/connection_tab.py b/app/widgets/connection_tab.py index c355eec..3570f9f 100644 --- a/app/widgets/connection_tab.py +++ b/app/widgets/connection_tab.py @@ -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) diff --git a/build_exe.ps1 b/build_exe.ps1 index b26307b..c60f552 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -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 "Очистка завершена." } diff --git a/build_linux.sh b/build_linux.sh new file mode 100644 index 0000000..a5fa9b1 --- /dev/null +++ b/build_linux.sh @@ -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" diff --git a/main.py b/main.py index 3b6df63..b898c90 100644 --- a/main.py +++ b/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 diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py new file mode 100644 index 0000000..c03a020 --- /dev/null +++ b/tests/test_auth_service.py @@ -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() diff --git a/tests/test_com_service.py b/tests/test_com_service.py new file mode 100644 index 0000000..ce80152 --- /dev/null +++ b/tests/test_com_service.py @@ -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() diff --git a/Система команд платы v2.x.md b/Система команд платы v2.x.md index 71c4000..d113e11 100644 --- a/Система команд платы v2.x.md +++ b/Система команд платы v2.x.md @@ -72,6 +72,12 @@ by: Паша Николаевич После загрузки контроллер не принимает никаких команд, кроме запросов на авторизацию. +После открытия COM-порта программа выполняет штатный аппаратный сброс контроллера +коротким импульсом RTS и затем ожидает отдельную строку +`periphery controller ver_<номер версии и комментарий>`. До её получения программа +не отправляет контроллеру команды. После получения строки программа выдерживает паузу +`2000 мс` и только затем начинает авторизацию. + Запрос – требование зашифровать строку шестнадцатеричных символов с помощью AES256 алгоритма, используя известный ключ. Компьютер генерирует случайную последовательность из 16 байт и отправляет ее контроллеру на шифрование. Тот ее шифрует и отвечает. Компьютер расшифровывает и проверяет на совпадение. @@ -93,7 +99,10 @@ by: Паша Николаевич 4. **Контроллер ответит на первый запрос компа**: **RAES\ (16 байт или 32 HEX-символа)** ***Пример:*** **RAES***99ED731648AB8C1709CC4C3C8C3BB0E1* -5. При успехе контроллер отправляет `AUTH_PC_OK` и статус. При ошибке - `AUTH_PC_FAIL`. +5. Контроллер может отправить промежуточные ответы `AUTH_PC_OK` и статус. +6. Авторизация считается полностью завершённой только после получения отдельной точной + строки `READY!`. Ответы `RAES`, `AUTH_PC_OK` и любые другие строки сами по себе не + означают успешного завершения авторизации. # Команды