Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35c9693740 | |||
| 3a4491f0c4 | |||
| add8aff0af | |||
| e163b63666 | |||
| c34cb0cd7e | |||
| 5513eb8a56 | |||
| 914d8defe3 | |||
| 7f73d2814b | |||
| 0ce06fc7f8 | |||
| 318342947b | |||
| 4ce9eb7689 | |||
| dfd4f0e0ba | |||
| f8cf28143a | |||
| 448b9bb07a | |||
| 85a7b4db55 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -14,6 +14,10 @@ env/
|
||||
# ── PyInstaller build ─────────────────────────────────
|
||||
build/
|
||||
dist/
|
||||
build-linux/
|
||||
dist-linux/
|
||||
.venv-linux/
|
||||
release-builds/
|
||||
*.manifest
|
||||
*.spec.bak
|
||||
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@ -12,11 +12,35 @@
|
||||
|
||||
## 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
|
||||
|
||||
- Перед тем как приступать к выполнению любой задачи (кроме тривиальных исправлений опечаток или ответов на вопросы), агент обязан составить подробный план реализации изменений.
|
||||
- План должен быть предоставлен пользователю на проверку и одобрение. Агент не должен приступать к написанию кода или выполнению команд до получения явного подтверждения от пользователя.
|
||||
|
||||
30
BUILD_EXE.md
30
BUILD_EXE.md
@ -1,5 +1,24 @@
|
||||
# Сборка 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 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-linux-x86_64`, папки
|
||||
конфигураций рядом с ним и архив
|
||||
`dist-linux/plate_config_app-linux-x86_64.zip`. Для автообновления публикуется
|
||||
ZIP-архив. Для работы с USB-COM пользователь Debian должен состоять в группе
|
||||
`dialout`.
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
- Python ≥ 3.11 (виртуальное окружение `.venv`)
|
||||
@ -12,7 +31,9 @@
|
||||
.\build_exe.ps1 -InstallBuildDeps
|
||||
```
|
||||
|
||||
Готовый файл: `dist\plate_config_app.exe`
|
||||
Готовые файлы: `dist\plate_config_app-windows-x86_64.exe` и
|
||||
`dist\plate_config_app-windows-x86_64.zip`. Для автообновления публикуется
|
||||
ZIP-архив.
|
||||
|
||||
## Команды по отдельности
|
||||
|
||||
@ -32,8 +53,10 @@ python -m pip install -r requirements-build.txt
|
||||
Скрипт автоматически:
|
||||
- очищает `build/`, `dist/`, `__pycache__/`
|
||||
- запускает PyInstaller по `plate_config_app.spec`
|
||||
- переименовывает бинарник в `plate_config_app-windows-x86_64.exe`
|
||||
- копирует папки `light_modes/`, `input_mappings/`, `commands/` в `dist/`
|
||||
- выводит путь и размер готового EXE
|
||||
- создаёт `plate_config_app-windows-x86_64.zip` с бинарником и ресурсами
|
||||
- выводит пути и размеры готовых EXE и ZIP
|
||||
|
||||
### 3. Только очистка (без сборки)
|
||||
|
||||
@ -75,4 +98,5 @@ Hidden imports: `qtawesome`, `Crypto.Cipher._raw_aes`, `Crypto.Util._raw_api`
|
||||
| `commands/` | Наборы команд (.csv) |
|
||||
|
||||
Эти файлы можно редактировать без пересборки — достаточно изменить их
|
||||
рядом с EXE и перезапустить приложение.
|
||||
рядом с EXE и перезапустить приложение. При установке OTA-обновления каталоги,
|
||||
которые входят в новый ZIP-пакет, полностью заменяются их новыми версиями.
|
||||
|
||||
@ -123,6 +123,11 @@ plate_config_app/
|
||||
Сервисы загружают данные с диска, управляют COM-соединением,
|
||||
выполняют авторизацию. Могут использоваться без GUI.
|
||||
|
||||
`ComConnection` хранит версию текущей прошивки и режим авторизации,
|
||||
определённые по стартовой строке. Маркер `no_auth` проверяется без учёта
|
||||
регистра; для неизвестной версии применяется строгий режим с AES-256
|
||||
handshake. После переподключения и OTA режим определяется заново.
|
||||
|
||||
2. **ui/** -- визуальное оформление. `theme.py` содержит палитры
|
||||
(`ThemePalette`) и QSS-шаблон с подстановкой переменных.
|
||||
`icons.py` -- обёртка над `qtawesome` с fallback при отсутствии
|
||||
@ -196,5 +201,6 @@ health-check) вынесены в `QThread`-подклассы. Связь с UI
|
||||
.\build_exe.ps1 -InstallBuildDeps
|
||||
```
|
||||
|
||||
Результат: `dist\plate_config_app.exe` + папки `dist\light_modes\`,
|
||||
Результат: `dist\plate_config_app-windows-x86_64.exe`, ZIP-архив
|
||||
`dist\plate_config_app-windows-x86_64.zip` и папки `dist\light_modes\`,
|
||||
`dist\input_mappings\`, `dist\commands\`.
|
||||
|
||||
52
README.md
52
README.md
@ -10,6 +10,7 @@
|
||||
|
||||
- [Системные требования](#системные-требования)
|
||||
- [Установка и запуск](#установка-и-запуск)
|
||||
- [Обновление приложения](#обновление-приложения)
|
||||
- [Интерфейс приложения](#интерфейс-приложения)
|
||||
- [Главная страница](#главная-страница)
|
||||
- [Подключение к плате](#подключение-к-плате)
|
||||
@ -23,6 +24,7 @@
|
||||
## Системные требования
|
||||
|
||||
- Windows 10 / 11
|
||||
- Debian 13 x86_64 (для Linux-сборки и запуска Linux-пакета)
|
||||
- COM-порт (физический или виртуальный), к которому подключена плата
|
||||
|
||||
Для запуска из собранного EXE дополнительных зависимостей не требуется.
|
||||
@ -31,10 +33,11 @@
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Из готового EXE
|
||||
### Из готовой сборки
|
||||
|
||||
1. Скопируйте `plate_config_app.exe` в любую папку.
|
||||
2. Запустите двойным кликом.
|
||||
1. Скопируйте сборку для своей платформы в каталог с папками ресурсов.
|
||||
2. На Windows запустите `plate_config_app-windows-x86_64.exe`.
|
||||
3. На Debian запустите `./plate_config_app-linux-x86_64`.
|
||||
|
||||
Файл настроек `settings.json` создается автоматически рядом с EXE при первом
|
||||
изменении настроек.
|
||||
@ -43,6 +46,38 @@
|
||||
|
||||
См. [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
Для Debian 13 x86_64 используйте [build_linux.sh](build_linux.sh); подробности
|
||||
сборки приведены в [BUILD_EXE.md](BUILD_EXE.md).
|
||||
|
||||
---
|
||||
|
||||
## Обновление приложения
|
||||
|
||||
При запуске приложение в фоне переходит по адресу
|
||||
`https://git.robotail.pro/Robotail/plate-config-app/releases/latest` и сравнивает
|
||||
тег последнего релиза с текущей версией. Кнопка «Скачать обновление» появляется
|
||||
под настройками только тогда, когда удалённый тег формата `vX.Y.Z` численно
|
||||
новее: сначала сравнивается major, затем minor и patch.
|
||||
|
||||
Приложение скачивает ZIP-пакет текущей платформы, проверяет его целостность и
|
||||
безопасность, завершает старый процесс, заменяет бинарник и все каталоги из
|
||||
пакета, а затем запускается заново. Перед заменой создаётся резервная копия;
|
||||
она удаляется только после подтверждения запуска новой версии. Если запуск
|
||||
не удался, предыдущая версия восстанавливается, а сообщение записывается
|
||||
в `update-error.log`. Файл `settings.json` и другие файлы,
|
||||
которых нет в архиве, сохраняются. Для работы проверки релизы репозитория должны
|
||||
быть доступны без авторизации.
|
||||
|
||||
Имена release-артефактов:
|
||||
|
||||
- Windows x86_64: `plate_config_app-windows-x86_64.zip`;
|
||||
- Linux x86_64: `plate_config_app-linux-x86_64.zip`.
|
||||
|
||||
Для перехода с уже установленной v2.0.1 на v2.0.2 обновите приложение вручную:
|
||||
закройте его, скачайте ZIP-пакет своей платформы и распакуйте содержимое в
|
||||
каталог приложения с заменой прежних файлов. `settings.json` удалять не нужно.
|
||||
Начиная с v2.0.2 последующие обновления можно устанавливать кнопкой в приложении.
|
||||
|
||||
---
|
||||
|
||||
## Интерфейс приложения
|
||||
@ -55,6 +90,8 @@
|
||||
- **Команды платы** -- справочник поддерживаемых команд платы с поиском.
|
||||
- **Настройки приложения** -- выбор цветовой темы (светлая / темная, зеленый /
|
||||
синий акцент).
|
||||
- **Скачать обновление** -- появляется под настройками, если версия последнего
|
||||
релиза формата `vX.Y.Z` новее версии запущенного приложения.
|
||||
|
||||
Приложение поддерживает несколько одновременных подключений: каждое соединение
|
||||
открывается в отдельной вкладке.
|
||||
@ -114,6 +151,15 @@
|
||||
- Зеленый — подключено, плата готова к работе.
|
||||
|
||||
Кнопка переподключения (↻) позволяет быстро восстановить связь при обрыве.
|
||||
При подключении приложение определяет режим по версии в стартовой строке платы.
|
||||
Если версия содержит маркер `no_auth` без учёта регистра, AES-256 авторизация
|
||||
пропускается, а статус отображается как «Готово без авторизации». Для остальных
|
||||
и неизвестных версий сохраняется строгая обязательная авторизация.
|
||||
|
||||
После успешного OTA и перезагрузки приложение повторно читает версию новой
|
||||
прошивки. Полная AES-256 авторизация выполняется только для прошивок без маркера
|
||||
`no_auth`; перед командой `04` повторная авторизация не выполняется. Кнопка
|
||||
ручной авторизации в терминале использует то же правило совместимости.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""Plate Config App package."""
|
||||
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .main_window import MainWindow
|
||||
from .services import settings_service
|
||||
from .services.update_service import signal_update_ready
|
||||
from .ui.icons import ICON_COLOR_PRIMARY, icon
|
||||
from .ui.theme import build_stylesheet, get_palette
|
||||
from .ui.theme import build_stylesheet, get_palette, create_qpalette
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@ -18,6 +20,7 @@ def main() -> None:
|
||||
|
||||
palette = get_palette(settings_service.theme_mode(),
|
||||
settings_service.theme_accent())
|
||||
app.setPalette(create_qpalette(palette))
|
||||
app.setStyleSheet(build_stylesheet(palette))
|
||||
app_icon = icon("fa5s.microchip", color=ICON_COLOR_PRIMARY)
|
||||
if not app_icon.isNull():
|
||||
@ -26,4 +29,5 @@ def main() -> None:
|
||||
if not app_icon.isNull():
|
||||
window.setWindowIcon(app_icon)
|
||||
window.show()
|
||||
QTimer.singleShot(0, signal_update_ready)
|
||||
sys.exit(app.exec())
|
||||
|
||||
@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .ui.icons import ICON_COLOR_ACCENT, ICON_COLOR_MUTED, ICON_COLOR_PRIMARY, icon
|
||||
from .ui.theme import build_stylesheet, get_palette
|
||||
from .ui.theme import build_stylesheet, get_palette, create_qpalette
|
||||
from .services import settings_service
|
||||
from .widgets.connection_tab import ConnectionTab
|
||||
from .widgets.start_tab import StartTab
|
||||
@ -32,47 +32,14 @@ from .widgets.title_bar import TitleBar
|
||||
from . import __version__
|
||||
|
||||
|
||||
class _WindowBorderOverlay(QWidget):
|
||||
"""Paints the accent border above all window content."""
|
||||
|
||||
def __init__(self, parent: QWidget, radius: int) -> None:
|
||||
super().__init__(parent)
|
||||
self._radius = radius
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
|
||||
self.setObjectName("WindowBorderOverlay")
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
parent = self.parentWidget()
|
||||
if parent is None:
|
||||
return
|
||||
if parent.isMaximized() or parent.isFullScreen():
|
||||
return
|
||||
|
||||
accent = get_palette(
|
||||
settings_service.theme_mode(),
|
||||
settings_service.theme_accent(),
|
||||
).accent
|
||||
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.setPen(QPen(QColor(accent), 2))
|
||||
rect = self.rect().adjusted(1, 1, -1, -1)
|
||||
p.drawRoundedRect(QRectF(rect), self._radius, self._radius)
|
||||
p.end()
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
_WINDOW_RADIUS = 10
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("AppWindow")
|
||||
self.setWindowTitle(f"Plate Config App v{__version__}")
|
||||
self.resize(820, 560)
|
||||
self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.setWindowFlags(Qt.WindowType.Window)
|
||||
self.setMinimumSize(760, 500)
|
||||
|
||||
container = QWidget()
|
||||
@ -104,38 +71,12 @@ class MainWindow(QMainWindow):
|
||||
container_layout.addWidget(self.content_frame, 1)
|
||||
|
||||
self.setCentralWidget(container)
|
||||
self._border_overlay = _WindowBorderOverlay(self, self._WINDOW_RADIUS)
|
||||
self._border_overlay.setGeometry(self.rect())
|
||||
self._border_overlay.raise_()
|
||||
|
||||
start_tab = StartTab(self.new_connection_tab)
|
||||
self._add_tab(start_tab, "Главная", icon_name="fa5s.home", closable=False)
|
||||
self.tabs.setCurrentIndex(0)
|
||||
|
||||
def _update_window_mask(self) -> None:
|
||||
"""Apply rounded mask so bottom corners are physically clipped."""
|
||||
if self.isMaximized() or self.isFullScreen():
|
||||
self.clearMask()
|
||||
return
|
||||
if self.width() <= 0 or self.height() <= 0:
|
||||
return
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(QRectF(self.rect()), self._WINDOW_RADIUS, self._WINDOW_RADIUS)
|
||||
self.setMask(QRegion(path.toFillPolygon().toPolygon()))
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._update_window_mask()
|
||||
self._border_overlay.setGeometry(self.rect())
|
||||
self._border_overlay.raise_()
|
||||
self._border_overlay.update()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
super().showEvent(event)
|
||||
self._update_window_mask()
|
||||
self._border_overlay.setGeometry(self.rect())
|
||||
self._border_overlay.raise_()
|
||||
self._border_overlay.update()
|
||||
|
||||
def new_connection_tab(self) -> None:
|
||||
tab: ConnectionTab | None = None
|
||||
@ -178,6 +119,8 @@ class MainWindow(QMainWindow):
|
||||
widget = self.pages.widget(i)
|
||||
if isinstance(widget, ConnectionTab):
|
||||
widget.close_serial()
|
||||
elif isinstance(widget, StartTab):
|
||||
widget.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _attach_close_button(self, index: int) -> None:
|
||||
@ -316,6 +259,7 @@ class MainWindow(QMainWindow):
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
palette = get_palette(mode, accent)
|
||||
app.setPalette(create_qpalette(palette))
|
||||
app.setStyleSheet(build_stylesheet(palette))
|
||||
|
||||
# Refresh icons on every page
|
||||
@ -326,7 +270,6 @@ class MainWindow(QMainWindow):
|
||||
refresh()
|
||||
|
||||
self._refresh_tab_icons()
|
||||
self._border_overlay.update()
|
||||
|
||||
def apply_runtime_settings(self) -> None:
|
||||
"""Propagate runtime settings changes to all connection tabs."""
|
||||
|
||||
@ -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,21 @@ from Crypto.Cipher import AES
|
||||
|
||||
from .com_service import ComConnection
|
||||
|
||||
# ── Фиксированный AES-256 ключ (32 байта) ──────────────────────────────
|
||||
_AES_KEY = bytes((
|
||||
STARTUP_BANNER_PREFIX = "periphery controller ver_"
|
||||
NO_AUTH_MARKER = "no_auth"
|
||||
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 +54,23 @@ class AuthStatus(Enum):
|
||||
class AuthResult:
|
||||
status: AuthStatus
|
||||
detail: str = ""
|
||||
startup_ready: bool = False
|
||||
firmware_version: str | None = None
|
||||
auth_skipped: bool = False
|
||||
|
||||
|
||||
def firmware_requires_auth(version: str) -> bool:
|
||||
"""Определить режим авторизации по строке версии прошивки."""
|
||||
return NO_AUTH_MARKER not in version.casefold()
|
||||
|
||||
|
||||
def _remember_firmware(conn: ComConnection, banner: str) -> str:
|
||||
version = banner[len(STARTUP_BANNER_PREFIX):].strip()
|
||||
conn.set_firmware_version(
|
||||
version,
|
||||
auth_required=firmware_requires_auth(version),
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def _aes_encrypt(plaintext: bytes, key: bytes = _AES_KEY) -> bytes:
|
||||
@ -53,14 +84,45 @@ 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!`` означает
|
||||
готовность. Для обнаруженной прошивки ``no_auth`` достаточно любого
|
||||
непустого ответа: отсутствие ответа считается потерей связи, а не
|
||||
запросом авторизации.
|
||||
"""
|
||||
if not conn.is_connected:
|
||||
return AuthStatus.DISCONNECTED
|
||||
@ -70,26 +132,47 @@ 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
|
||||
if conn.auth_required is False:
|
||||
response = _read_until(conn, bool, timeout)
|
||||
return AuthStatus.OK if response is not None else AuthStatus.DISCONNECTED
|
||||
|
||||
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_required_handshake(
|
||||
conn: ComConnection,
|
||||
timeout: float = 2.0,
|
||||
ready_timeout: float = READY_TIMEOUT_SECONDS,
|
||||
) -> AuthResult:
|
||||
"""Выполнить handshake только если он требуется текущей прошивке."""
|
||||
if not conn.is_connected:
|
||||
return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт")
|
||||
if conn.auth_required is False:
|
||||
version = conn.firmware_version
|
||||
detail = "Авторизация не требуется: обнаружена прошивка no_auth"
|
||||
if version:
|
||||
detail = f"Прошивка {version}: авторизация не требуется"
|
||||
return AuthResult(
|
||||
AuthStatus.OK,
|
||||
detail,
|
||||
firmware_version=version,
|
||||
auth_skipped=True,
|
||||
)
|
||||
return perform_handshake(
|
||||
conn,
|
||||
timeout=timeout,
|
||||
ready_timeout=ready_timeout,
|
||||
)
|
||||
|
||||
|
||||
def perform_handshake(
|
||||
conn: ComConnection,
|
||||
timeout: float = 2.0,
|
||||
ready_timeout: float = READY_TIMEOUT_SECONDS,
|
||||
) -> AuthResult:
|
||||
"""Выполнить полный цикл двойного рукопожатия.
|
||||
|
||||
Returns:
|
||||
@ -108,61 +191,116 @@ 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}")
|
||||
version = _remember_firmware(conn, banner)
|
||||
sleep_fn(startup_delay)
|
||||
if not conn.is_connected:
|
||||
return AuthResult(
|
||||
AuthStatus.DISCONNECTED,
|
||||
"Порт закрыт до проверки режима авторизации",
|
||||
startup_ready=True,
|
||||
firmware_version=version,
|
||||
)
|
||||
|
||||
# Ждём AUTH_CNT_OK / READY после RAES
|
||||
ready_resp = conn.read_response(timeout=timeout)
|
||||
conn.release()
|
||||
if ready_resp:
|
||||
return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({ready_resp})")
|
||||
if not firmware_requires_auth(version):
|
||||
conn.release()
|
||||
return AuthResult(
|
||||
AuthStatus.OK,
|
||||
f"{banner}\nАвторизация не требуется для прошивки no_auth",
|
||||
startup_ready=True,
|
||||
firmware_version=version,
|
||||
auth_skipped=True,
|
||||
)
|
||||
|
||||
return AuthResult(AuthStatus.OK, "Авторизация пройдена")
|
||||
result = perform_handshake(
|
||||
conn,
|
||||
timeout=handshake_timeout,
|
||||
ready_timeout=ready_timeout,
|
||||
)
|
||||
result.startup_ready = True
|
||||
result.firmware_version = version
|
||||
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]:
|
||||
@ -90,6 +91,8 @@ class ComConnection(QObject):
|
||||
# Параметры последнего подключения (для reconnect)
|
||||
self._last_port: str | None = None
|
||||
self._last_params: SerialParams | None = None
|
||||
self._firmware_version: str | None = None
|
||||
self._auth_required: bool | None = None
|
||||
|
||||
# ── Properties ────────────────────────────────────────────────────
|
||||
|
||||
@ -101,6 +104,20 @@ class ComConnection(QObject):
|
||||
def serial(self) -> Serial | None:
|
||||
return self._serial
|
||||
|
||||
@property
|
||||
def firmware_version(self) -> str | None:
|
||||
return self._firmware_version
|
||||
|
||||
@property
|
||||
def auth_required(self) -> bool | None:
|
||||
"""Требуется ли авторизация по данным текущей прошивки."""
|
||||
return self._auth_required
|
||||
|
||||
def set_firmware_version(self, version: str, *, auth_required: bool) -> None:
|
||||
"""Сохранить версию и обнаруженный режим авторизации прошивки."""
|
||||
self._firmware_version = version.strip()
|
||||
self._auth_required = auth_required
|
||||
|
||||
@property
|
||||
def seconds_since_last_command(self) -> float:
|
||||
"""Сколько секунд прошло с последней отправки команды."""
|
||||
@ -110,7 +127,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 +149,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 +183,17 @@ 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()
|
||||
self._firmware_version = None
|
||||
self._auth_required = None
|
||||
|
||||
def reconnect(self) -> None:
|
||||
def reconnect(
|
||||
self,
|
||||
*,
|
||||
drain_paused: bool = False,
|
||||
reset_device: bool = False,
|
||||
) -> None:
|
||||
"""Переподключиться с параметрами последнего open().
|
||||
|
||||
Raises:
|
||||
@ -159,7 +202,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 +304,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:
|
||||
|
||||
100
app/services/firmware_service.py
Normal file
100
app/services/firmware_service.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Запуск OTA с повторной авторизацией после обновления контроллера."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .auth_service import (
|
||||
AuthResult,
|
||||
AuthStatus,
|
||||
READY_TIMEOUT_SECONDS,
|
||||
STARTUP_BANNER_TIMEOUT_SECONDS,
|
||||
STARTUP_DELAY_SECONDS,
|
||||
perform_startup_handshake,
|
||||
)
|
||||
from .com_service import ComConnection
|
||||
|
||||
OTA_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareUpdateResult:
|
||||
success: bool
|
||||
detail: str
|
||||
post_auth: AuthResult | None = None
|
||||
|
||||
|
||||
def perform_authenticated_ota(
|
||||
conn: ComConnection,
|
||||
*,
|
||||
log_message: Callable[[str], None] | None = None,
|
||||
ota_timeout: float = OTA_TIMEOUT_SECONDS,
|
||||
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,
|
||||
) -> FirmwareUpdateResult:
|
||||
"""Выполнить ``04`` и полностью авторизоваться после успешного OTA."""
|
||||
emit = log_message or (lambda _message: None)
|
||||
if not conn.is_connected:
|
||||
return FirmwareUpdateResult(False, "Порт не подключён")
|
||||
|
||||
try:
|
||||
conn.send_command("04")
|
||||
emit("→ 04")
|
||||
|
||||
deadline = time.monotonic() + ota_timeout
|
||||
update_confirmed = False
|
||||
while time.monotonic() < deadline and conn.is_connected:
|
||||
remaining = max(deadline - time.monotonic(), 0.1)
|
||||
response = conn.read_response(timeout=min(remaining, 2.0))
|
||||
if response is None:
|
||||
continue
|
||||
emit(f"← {response}")
|
||||
if "successful update" in response.lower():
|
||||
update_confirmed = True
|
||||
break
|
||||
|
||||
if not update_confirmed:
|
||||
return FirmwareUpdateResult(
|
||||
False,
|
||||
"Таймаут ожидания подтверждения успешного обновления",
|
||||
)
|
||||
|
||||
emit("✓ Обновление подтверждено. Ожидание перезагрузки платы…")
|
||||
post_auth = perform_startup_handshake(
|
||||
conn,
|
||||
banner_timeout=banner_timeout,
|
||||
startup_delay=startup_delay,
|
||||
handshake_timeout=handshake_timeout,
|
||||
ready_timeout=ready_timeout,
|
||||
sleep_fn=sleep_fn,
|
||||
)
|
||||
if post_auth.status != AuthStatus.OK:
|
||||
return FirmwareUpdateResult(
|
||||
False,
|
||||
f"Авторизация после OTA не выполнена: {post_auth.detail}",
|
||||
post_auth=post_auth,
|
||||
)
|
||||
|
||||
if post_auth.auth_skipped:
|
||||
emit("✓ Прошивка no_auth: авторизация после OTA не требуется.")
|
||||
detail = "Прошивка обновлена, плата готова без авторизации"
|
||||
else:
|
||||
emit("✓ Авторизация после OTA выполнена.")
|
||||
detail = "Прошивка обновлена, плата перезагружена и авторизована"
|
||||
return FirmwareUpdateResult(
|
||||
True,
|
||||
detail,
|
||||
post_auth=post_auth,
|
||||
)
|
||||
except Exception as exc:
|
||||
return FirmwareUpdateResult(
|
||||
False,
|
||||
f"Ошибка OTA: {exc}",
|
||||
)
|
||||
finally:
|
||||
conn.release()
|
||||
648
app/services/update_service.py
Normal file
648
app/services/update_service.py
Normal file
@ -0,0 +1,648 @@
|
||||
"""Проверка, загрузка и установка обновлений Plate Config App."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import BinaryIO
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
LATEST_RELEASE_URL = (
|
||||
"https://git.robotail.pro/Robotail/plate-config-app/releases/latest"
|
||||
)
|
||||
RELEASE_DOWNLOAD_BASE_URL = (
|
||||
"https://git.robotail.pro/Robotail/plate-config-app/releases/download"
|
||||
)
|
||||
UPDATE_HOST = "git.robotail.pro"
|
||||
HTTP_TIMEOUT_SECONDS = 10.0
|
||||
DOWNLOAD_TIMEOUT_SECONDS = 30.0
|
||||
DOWNLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024
|
||||
MAX_UNPACKED_BYTES = 600 * 1024 * 1024
|
||||
MAX_ARCHIVE_MEMBERS = 10_000
|
||||
UPDATE_READY_ENV = "PLATE_CONFIG_UPDATE_READY_FILE"
|
||||
VERSION_PATTERN = re.compile(
|
||||
r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
||||
)
|
||||
TOP_LEVEL_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
||||
|
||||
|
||||
class UpdateError(RuntimeError):
|
||||
"""Ошибка проверки, загрузки или запуска обновления."""
|
||||
|
||||
|
||||
class UpdateCancelled(UpdateError):
|
||||
"""Загрузка обновления отменена пользователем или закрытием приложения."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateInfo:
|
||||
tag: str
|
||||
version: str
|
||||
asset_name: str
|
||||
download_url: str
|
||||
|
||||
|
||||
def normalize_version(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized[:1].casefold() == "v":
|
||||
normalized = normalized[1:]
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple[int, int, int]:
|
||||
"""Преобразовать версию X.Y.Z или vX.Y.Z в числовой кортеж."""
|
||||
match = VERSION_PATTERN.fullmatch(value.strip())
|
||||
if match is None:
|
||||
raise UpdateError(
|
||||
f"Некорректная версия {value!r}: ожидается формат vX.Y.Z"
|
||||
)
|
||||
return tuple(int(component) for component in match.groups())
|
||||
|
||||
|
||||
def platform_executable_name(
|
||||
*,
|
||||
system: str | None = None,
|
||||
machine: str | None = None,
|
||||
) -> str:
|
||||
system_name = (system or platform.system()).casefold()
|
||||
machine_name = (machine or platform.machine()).casefold()
|
||||
if machine_name not in {"amd64", "x86_64"}:
|
||||
raise UpdateError(f"Автообновление не поддерживает архитектуру {machine_name}")
|
||||
if system_name == "windows":
|
||||
return "plate_config_app-windows-x86_64.exe"
|
||||
if system_name == "linux":
|
||||
return "plate_config_app-linux-x86_64"
|
||||
raise UpdateError(f"Автообновление не поддерживает платформу {system_name}")
|
||||
|
||||
|
||||
def platform_asset_name(
|
||||
*,
|
||||
system: str | None = None,
|
||||
machine: str | None = None,
|
||||
) -> str:
|
||||
executable_name = platform_executable_name(system=system, machine=machine)
|
||||
if executable_name.casefold().endswith(".exe"):
|
||||
executable_name = executable_name[:-4]
|
||||
return f"{executable_name}.zip"
|
||||
|
||||
|
||||
def archive_executable_name(asset_name: str) -> str:
|
||||
names = {
|
||||
"plate_config_app-windows-x86_64.zip": (
|
||||
"plate_config_app-windows-x86_64.exe"
|
||||
),
|
||||
"plate_config_app-linux-x86_64.zip": "plate_config_app-linux-x86_64",
|
||||
}
|
||||
try:
|
||||
return names[asset_name]
|
||||
except KeyError as exc:
|
||||
raise UpdateError("Неизвестное имя архива обновления") from exc
|
||||
|
||||
|
||||
def extract_release_tag(final_url: str) -> str:
|
||||
parsed = urlparse(final_url)
|
||||
if parsed.scheme != "https" or parsed.hostname != UPDATE_HOST:
|
||||
raise UpdateError("Сервер релизов перенаправил запрос на неизвестный адрес")
|
||||
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
||||
try:
|
||||
release_index = parts.index("releases")
|
||||
except ValueError as exc:
|
||||
raise UpdateError("Не удалось определить тег последнего релиза") from exc
|
||||
if parts[release_index + 1:release_index + 2] != ["tag"]:
|
||||
raise UpdateError("Не удалось определить тег последнего релиза")
|
||||
tag_parts = parts[release_index + 2:release_index + 3]
|
||||
if not tag_parts or not tag_parts[0].strip():
|
||||
raise UpdateError("Сервер релизов вернул пустой тег")
|
||||
return tag_parts[0].strip()
|
||||
|
||||
|
||||
def build_download_url(tag: str, asset_name: str) -> str:
|
||||
encoded_tag = quote(tag, safe="")
|
||||
encoded_asset = quote(asset_name, safe="")
|
||||
return f"{RELEASE_DOWNLOAD_BASE_URL}/{encoded_tag}/{encoded_asset}"
|
||||
|
||||
|
||||
def check_for_update(
|
||||
current_version: str,
|
||||
*,
|
||||
timeout: float = HTTP_TIMEOUT_SECONDS,
|
||||
opener: Callable[..., BinaryIO] | None = None,
|
||||
system: str | None = None,
|
||||
machine: str | None = None,
|
||||
) -> UpdateInfo | None:
|
||||
"""Перейти по latest URL и вернуть данные отличающегося релиза."""
|
||||
request = Request(
|
||||
LATEST_RELEASE_URL,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"User-Agent": f"PlateConfigApp/{normalize_version(current_version)}",
|
||||
},
|
||||
)
|
||||
open_request = opener or urlopen
|
||||
try:
|
||||
with open_request(request, timeout=timeout) as response:
|
||||
final_url = response.geturl()
|
||||
except Exception as exc:
|
||||
raise UpdateError(f"Не удалось проверить обновления: {exc}") from exc
|
||||
|
||||
tag = extract_release_tag(final_url)
|
||||
remote_version = parse_version(tag)
|
||||
if remote_version <= parse_version(current_version):
|
||||
return None
|
||||
|
||||
version = normalize_version(tag)
|
||||
asset_name = platform_asset_name(system=system, machine=machine)
|
||||
return UpdateInfo(
|
||||
tag=tag,
|
||||
version=version,
|
||||
asset_name=asset_name,
|
||||
download_url=build_download_url(tag, asset_name),
|
||||
)
|
||||
|
||||
|
||||
def _validate_executable_signature(signature: bytes, executable_name: str) -> None:
|
||||
if executable_name.casefold().endswith(".exe"):
|
||||
valid = signature.startswith(b"MZ")
|
||||
else:
|
||||
valid = signature == b"\x7fELF"
|
||||
if not valid:
|
||||
raise UpdateError("Сервер вернул файл, не похожий на исполняемый артефакт")
|
||||
|
||||
|
||||
def _validated_archive_members(
|
||||
archive: zipfile.ZipFile,
|
||||
executable_name: str,
|
||||
) -> list[zipfile.ZipInfo]:
|
||||
members = archive.infolist()
|
||||
if len(members) > MAX_ARCHIVE_MEMBERS:
|
||||
raise UpdateError("В архиве обновления слишком много файлов")
|
||||
|
||||
total_size = 0
|
||||
normalized_names: set[str] = set()
|
||||
executable_member: zipfile.ZipInfo | None = None
|
||||
for member in members:
|
||||
raw_name = member.filename
|
||||
if not raw_name or "\\" in raw_name or "\x00" in raw_name:
|
||||
raise UpdateError("Архив обновления содержит небезопасный путь")
|
||||
relative_path = PurePosixPath(raw_name.rstrip("/"))
|
||||
if (
|
||||
relative_path.is_absolute()
|
||||
or not relative_path.parts
|
||||
or any(part in {"", ".", ".."} for part in relative_path.parts)
|
||||
):
|
||||
raise UpdateError("Архив обновления содержит небезопасный путь")
|
||||
if not TOP_LEVEL_NAME_PATTERN.fullmatch(relative_path.parts[0]):
|
||||
raise UpdateError("Архив обновления содержит недопустимое имя")
|
||||
|
||||
normalized_name = relative_path.as_posix().casefold()
|
||||
if normalized_name in normalized_names:
|
||||
raise UpdateError("Архив обновления содержит повторяющиеся пути")
|
||||
normalized_names.add(normalized_name)
|
||||
|
||||
unix_mode = member.external_attr >> 16
|
||||
file_type = stat.S_IFMT(unix_mode)
|
||||
if file_type not in {0, stat.S_IFREG, stat.S_IFDIR}:
|
||||
raise UpdateError("Архив обновления содержит ссылку или специальный файл")
|
||||
if member.flag_bits & 0x1:
|
||||
raise UpdateError("Зашифрованные архивы обновления не поддерживаются")
|
||||
|
||||
if not member.is_dir():
|
||||
total_size += member.file_size
|
||||
if total_size > MAX_UNPACKED_BYTES:
|
||||
raise UpdateError("Распакованный размер обновления превышает предел")
|
||||
if relative_path.as_posix() == executable_name:
|
||||
if member.is_dir():
|
||||
raise UpdateError("Исполняемый файл обновления имеет неверный тип")
|
||||
executable_member = member
|
||||
|
||||
if executable_member is None:
|
||||
raise UpdateError("В архиве обновления не найден исполняемый файл")
|
||||
with archive.open(executable_member, "r") as executable:
|
||||
_validate_executable_signature(executable.read(4), executable_name)
|
||||
return members
|
||||
|
||||
|
||||
def _validate_update_archive(path: Path, executable_name: str) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(path, "r") as archive:
|
||||
_validated_archive_members(archive, executable_name)
|
||||
bad_member = archive.testzip()
|
||||
except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
|
||||
raise UpdateError(f"Не удалось проверить ZIP-архив обновления: {exc}") from exc
|
||||
if bad_member is not None:
|
||||
raise UpdateError(f"Повреждён файл в архиве обновления: {bad_member}")
|
||||
|
||||
|
||||
def _extract_update_archive(
|
||||
archive_path: Path,
|
||||
staging_directory: Path,
|
||||
executable_name: str,
|
||||
) -> Path:
|
||||
try:
|
||||
staging_directory.mkdir()
|
||||
with zipfile.ZipFile(archive_path, "r") as archive:
|
||||
members = _validated_archive_members(archive, executable_name)
|
||||
for member in members:
|
||||
relative_path = PurePosixPath(member.filename.rstrip("/"))
|
||||
destination = staging_directory.joinpath(*relative_path.parts)
|
||||
if member.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
archive.open(member, "r") as source,
|
||||
destination.open("xb") as output,
|
||||
):
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
executable_path = staging_directory / executable_name
|
||||
with executable_path.open("rb") as executable:
|
||||
_validate_executable_signature(executable.read(4), executable_name)
|
||||
if not executable_name.casefold().endswith(".exe"):
|
||||
executable_path.chmod(0o755)
|
||||
return executable_path
|
||||
except Exception:
|
||||
shutil.rmtree(staging_directory, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def download_update(
|
||||
info: UpdateInfo,
|
||||
target_directory: Path,
|
||||
*,
|
||||
timeout: float = DOWNLOAD_TIMEOUT_SECONDS,
|
||||
opener: Callable[..., BinaryIO] | None = None,
|
||||
progress: Callable[[int], None] | None = None,
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Path:
|
||||
"""Скачать и проверить ZIP-пакет обновления рядом с приложением."""
|
||||
target_directory = target_directory.resolve()
|
||||
if not target_directory.is_dir():
|
||||
raise UpdateError("Каталог приложения не найден")
|
||||
if not os.access(target_directory, os.W_OK):
|
||||
raise UpdateError("Нет прав на запись в каталог приложения")
|
||||
|
||||
temporary_path = target_directory / (
|
||||
f".{info.asset_name}.download-{uuid.uuid4().hex}"
|
||||
)
|
||||
request = Request(
|
||||
info.download_url,
|
||||
headers={
|
||||
"Accept": "application/octet-stream",
|
||||
"User-Agent": f"PlateConfigAppUpdater/{info.version}",
|
||||
},
|
||||
)
|
||||
open_request = opener or urlopen
|
||||
received = 0
|
||||
try:
|
||||
with open_request(request, timeout=timeout) as response:
|
||||
final_url = urlparse(response.geturl())
|
||||
if final_url.scheme != "https" or final_url.hostname != UPDATE_HOST:
|
||||
raise UpdateError("Загрузка перенаправлена на неизвестный сервер")
|
||||
if final_url.path.rstrip("/") == "/user/login":
|
||||
raise UpdateError("Release-артефакты недоступны без авторизации")
|
||||
content_length = response.headers.get("Content-Length")
|
||||
total = int(content_length) if content_length else 0
|
||||
if total > MAX_DOWNLOAD_BYTES:
|
||||
raise UpdateError("Размер обновления превышает допустимый предел")
|
||||
|
||||
with temporary_path.open("wb") as output:
|
||||
while True:
|
||||
if cancelled is not None and cancelled():
|
||||
raise UpdateCancelled("Загрузка обновления отменена")
|
||||
chunk = response.read(DOWNLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
received += len(chunk)
|
||||
if received > MAX_DOWNLOAD_BYTES:
|
||||
raise UpdateError("Размер обновления превышает допустимый предел")
|
||||
output.write(chunk)
|
||||
if progress is not None and total > 0:
|
||||
progress(min(received * 100 // total, 100))
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
|
||||
if received == 0:
|
||||
raise UpdateError("Сервер вернул пустой файл обновления")
|
||||
if total > 0 and received != total:
|
||||
raise UpdateError("Файл обновления скачан не полностью")
|
||||
executable_name = archive_executable_name(info.asset_name)
|
||||
_validate_update_archive(temporary_path, executable_name)
|
||||
if progress is not None:
|
||||
progress(100)
|
||||
return temporary_path
|
||||
except Exception:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def current_executable() -> Path:
|
||||
if not getattr(sys, "frozen", False):
|
||||
raise UpdateError("Самообновление доступно только в собранной версии")
|
||||
return Path(sys.executable).resolve()
|
||||
|
||||
|
||||
def signal_update_ready() -> None:
|
||||
ready_file = os.environ.pop(UPDATE_READY_ENV, None)
|
||||
if ready_file:
|
||||
try:
|
||||
Path(ready_file).write_text(str(os.getpid()), encoding="ascii")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_windows_updater(script_path: Path) -> None:
|
||||
script = r'''param(
|
||||
[int]$ApplicationProcessId,
|
||||
[string]$Staging,
|
||||
[string]$Target,
|
||||
[string]$Backup,
|
||||
[string]$ReadyFile
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$applicationDirectory = Split-Path -Parent $Target
|
||||
$errorLog = Join-Path $applicationDirectory "update-error.log"
|
||||
$installed = [System.Collections.Generic.List[string]]::new()
|
||||
$backedUp = [System.Collections.Generic.List[string]]::new()
|
||||
$restarted = $null
|
||||
try {
|
||||
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $Backup | Out-Null
|
||||
$entries = @(Get-ChildItem -LiteralPath $Staging -Force)
|
||||
foreach ($entry in $entries) {
|
||||
$destination = Join-Path $applicationDirectory $entry.Name
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
while ($true) {
|
||||
try {
|
||||
Move-Item -LiteralPath $destination -Destination $Backup -Force -ErrorAction Stop
|
||||
break
|
||||
} catch {
|
||||
if ((Get-Date) -ge $deadline) { throw }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
$backedUp.Add($entry.Name)
|
||||
}
|
||||
Move-Item -LiteralPath $entry.FullName -Destination $destination -Force -ErrorAction Stop
|
||||
$installed.Add($entry.Name)
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) {
|
||||
throw "В пакете обновления отсутствует исполняемый файл"
|
||||
}
|
||||
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
||||
$previousReadyFile = $env:PLATE_CONFIG_UPDATE_READY_FILE
|
||||
$previousResetEnvironment = $env:PYINSTALLER_RESET_ENVIRONMENT
|
||||
try {
|
||||
$env:PLATE_CONFIG_UPDATE_READY_FILE = $ReadyFile
|
||||
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
|
||||
$restarted = Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory -PassThru
|
||||
} finally {
|
||||
$env:PLATE_CONFIG_UPDATE_READY_FILE = $previousReadyFile
|
||||
$env:PYINSTALLER_RESET_ENVIRONMENT = $previousResetEnvironment
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(60)
|
||||
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
|
||||
if ($restarted.HasExited) { throw "Новая версия завершилась до запуска интерфейса" }
|
||||
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
|
||||
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
|
||||
} catch {
|
||||
$failure = $_
|
||||
if ($restarted -and -not $restarted.HasExited) {
|
||||
& taskkill.exe /PID $restarted.Id /T /F *> $null
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($name in $installed) {
|
||||
$destination = Join-Path $applicationDirectory $name
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$rollbackErrors.Add($_.ToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($name in $backedUp) {
|
||||
$backupItem = Join-Path $Backup $name
|
||||
$destination = Join-Path $applicationDirectory $name
|
||||
if (Test-Path -LiteralPath $backupItem) {
|
||||
try {
|
||||
Move-Item -LiteralPath $backupItem -Destination $destination -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$rollbackErrors.Add($_.ToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($rollbackErrors.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@($failure.ToString()) + @($rollbackErrors) | Out-File -LiteralPath $errorLog -Encoding utf8
|
||||
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
'''
|
||||
script_path.write_text(script, encoding="utf-8-sig", newline="\r\n")
|
||||
|
||||
|
||||
def _write_linux_updater(script_path: Path) -> None:
|
||||
script = r'''#!/bin/sh
|
||||
set -eu
|
||||
application_pid="$1"
|
||||
staging_path="$2"
|
||||
target_path="$3"
|
||||
backup_path="$4"
|
||||
ready_path="$5"
|
||||
application_dir="$(dirname -- "$target_path")"
|
||||
error_log="$application_dir/update-error.log"
|
||||
installed_file="$backup_path/.installed"
|
||||
backed_up_file="$backup_path/.backed_up"
|
||||
new_pid=""
|
||||
rollback() {
|
||||
exit_code="$?"
|
||||
trap - EXIT HUP INT TERM
|
||||
set +e
|
||||
if [ -n "$new_pid" ]; then
|
||||
kill "$new_pid" 2>/dev/null
|
||||
wait "$new_pid" 2>/dev/null
|
||||
fi
|
||||
rollback_failed=0
|
||||
if [ -f "$installed_file" ]; then
|
||||
while IFS= read -r name; do
|
||||
rm -rf -- "$application_dir/$name" || rollback_failed=1
|
||||
done < "$installed_file"
|
||||
fi
|
||||
if [ -f "$backed_up_file" ]; then
|
||||
while IFS= read -r name; do
|
||||
if [ -e "$backup_path/$name" ]; then
|
||||
mv -f -- "$backup_path/$name" "$application_dir/$name" || rollback_failed=1
|
||||
fi
|
||||
done < "$backed_up_file"
|
||||
fi
|
||||
rm -f -- "$ready_path"
|
||||
rm -rf -- "$staging_path"
|
||||
if [ "$rollback_failed" -eq 0 ]; then
|
||||
rm -rf -- "$backup_path"
|
||||
fi
|
||||
printf '%s\n' "Не удалось заменить приложение" >"$error_log"
|
||||
rm -f -- "$0"
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
exit_code=1
|
||||
fi
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap rollback EXIT HUP INT TERM
|
||||
while kill -0 "$application_pid" 2>/dev/null; do
|
||||
sleep 0.2
|
||||
done
|
||||
rm -rf -- "$backup_path"
|
||||
mkdir -p -- "$backup_path"
|
||||
: >"$installed_file"
|
||||
: >"$backed_up_file"
|
||||
for source_path in "$staging_path"/*; do
|
||||
name="$(basename -- "$source_path")"
|
||||
destination="$application_dir/$name"
|
||||
if [ -e "$destination" ]; then
|
||||
mv -f -- "$destination" "$backup_path/$name"
|
||||
printf '%s\n' "$name" >>"$backed_up_file"
|
||||
fi
|
||||
mv -f -- "$source_path" "$destination"
|
||||
printf '%s\n' "$name" >>"$installed_file"
|
||||
done
|
||||
test -f "$target_path"
|
||||
chmod 755 -- "$target_path"
|
||||
rm -f -- "$ready_path"
|
||||
PLATE_CONFIG_UPDATE_READY_FILE="$ready_path" PYINSTALLER_RESET_ENVIRONMENT=1 "$target_path" >/dev/null 2>&1 &
|
||||
new_pid=$!
|
||||
attempts=0
|
||||
while [ ! -f "$ready_path" ]; do
|
||||
if ! kill -0 "$new_pid" 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 240 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
if ! kill -0 "$new_pid" 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT HUP INT TERM
|
||||
rm -f -- "$ready_path"
|
||||
rm -rf -- "$backup_path" "$staging_path"
|
||||
rm -f -- "$0"
|
||||
'''
|
||||
script_path.write_text(script, encoding="utf-8", newline="\n")
|
||||
script_path.chmod(0o700)
|
||||
|
||||
|
||||
def launch_update(
|
||||
downloaded_path: Path,
|
||||
*,
|
||||
executable_path: Path | None = None,
|
||||
process_id: int | None = None,
|
||||
system: str | None = None,
|
||||
) -> None:
|
||||
"""Распаковать обновление и запустить helper для замены пакета."""
|
||||
archive_path = downloaded_path.resolve()
|
||||
target = (executable_path or current_executable()).resolve()
|
||||
if not archive_path.is_file():
|
||||
raise UpdateError("Скачанный архив обновления не найден")
|
||||
if not os.access(target.parent, os.W_OK):
|
||||
raise UpdateError("Нет прав на замену приложения")
|
||||
|
||||
updater_id = uuid.uuid4().hex
|
||||
application_pid = process_id or os.getpid()
|
||||
system_name = (system or platform.system()).casefold()
|
||||
if system_name not in {"windows", "linux"}:
|
||||
raise UpdateError(f"Автообновление не поддерживает платформу {system_name}")
|
||||
|
||||
staging_path = target.parent / f".plate_config_update-{updater_id}"
|
||||
backup_path = target.parent / f".plate_config_backup-{updater_id}"
|
||||
ready_path = target.parent / f".plate_config_ready-{updater_id}"
|
||||
script_path: Path | None = None
|
||||
try:
|
||||
_extract_update_archive(archive_path, staging_path, target.name)
|
||||
archive_path.unlink()
|
||||
except Exception:
|
||||
shutil.rmtree(staging_path, ignore_errors=True)
|
||||
raise
|
||||
|
||||
if system_name == "windows":
|
||||
script_path = target.parent / f".plate_config_updater-{updater_id}.ps1"
|
||||
try:
|
||||
_write_windows_updater(script_path)
|
||||
creation_flags = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
)
|
||||
subprocess.Popen(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script_path),
|
||||
str(application_pid),
|
||||
str(staging_path),
|
||||
str(target),
|
||||
str(backup_path),
|
||||
str(ready_path),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
except Exception:
|
||||
script_path.unlink(missing_ok=True)
|
||||
shutil.rmtree(staging_path, ignore_errors=True)
|
||||
raise
|
||||
return
|
||||
if system_name == "linux":
|
||||
script_path = target.parent / f".plate_config_updater-{updater_id}.sh"
|
||||
try:
|
||||
_write_linux_updater(script_path)
|
||||
subprocess.Popen(
|
||||
[
|
||||
"/bin/sh",
|
||||
str(script_path),
|
||||
str(application_pid),
|
||||
str(staging_path),
|
||||
str(target),
|
||||
str(backup_path),
|
||||
str(ready_path),
|
||||
],
|
||||
cwd=str(target.parent),
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
script_path.unlink(missing_ok=True)
|
||||
shutil.rmtree(staging_path, ignore_errors=True)
|
||||
raise
|
||||
return
|
||||
@ -11,6 +11,8 @@ from dataclasses import dataclass, asdict
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemePalette:
|
||||
@ -219,32 +221,25 @@ def get_palette(mode: str = "light", accent: str = "green") -> ThemePalette:
|
||||
|
||||
_QSS_TEMPLATE = """
|
||||
QMainWindow#AppWindow {{
|
||||
background: transparent;
|
||||
background: {bg_base};
|
||||
}}
|
||||
|
||||
QWidget#MainContainer {{
|
||||
background: {bg_base};
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}}
|
||||
|
||||
QFrame#ContentArea {{
|
||||
background: {bg_base};
|
||||
border: none;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}}
|
||||
|
||||
QStackedWidget {{
|
||||
background: {bg_base};
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}}
|
||||
|
||||
QScrollArea {{
|
||||
background: {bg_base};
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}}
|
||||
|
||||
QScrollArea > QWidget > QWidget {{
|
||||
@ -253,11 +248,10 @@ QScrollArea > QWidget > QWidget {{
|
||||
|
||||
QWidget#TitleBar {{
|
||||
background: {accent};
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom: 1px solid {accent};
|
||||
}}
|
||||
|
||||
|
||||
QLabel#TitleBarTitle {{
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
@ -571,25 +565,6 @@ QMessageBox, QDialog {{
|
||||
color: {text_primary};
|
||||
}}
|
||||
|
||||
QWidget#DialogContainer {{
|
||||
background: {bg_dialog};
|
||||
border: 2px solid {accent};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
|
||||
QWidget#DialogTitleBar {{
|
||||
background: {accent};
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom: 1px solid {accent};
|
||||
}}
|
||||
|
||||
QLabel#DialogTitle {{
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
QMessageBox QLabel {{
|
||||
color: {text_primary};
|
||||
font-size: 13px;
|
||||
@ -859,6 +834,24 @@ def _arrow_svg_path(name: str, color: str, points: str) -> str:
|
||||
return fpath.replace("\\", "/")
|
||||
|
||||
|
||||
def create_qpalette(palette: ThemePalette) -> QPalette:
|
||||
qpal = QPalette()
|
||||
qpal.setColor(QPalette.Window, QColor(palette.bg_base))
|
||||
qpal.setColor(QPalette.WindowText, QColor(palette.text_primary))
|
||||
qpal.setColor(QPalette.Base, QColor(palette.bg_card))
|
||||
qpal.setColor(QPalette.AlternateBase, QColor(palette.bg_input))
|
||||
qpal.setColor(QPalette.ToolTipBase, QColor(palette.bg_tooltip))
|
||||
qpal.setColor(QPalette.ToolTipText, QColor(
|
||||
palette.text_on_accent if "#1e" in palette.bg_tooltip or "#0" in palette.bg_tooltip else palette.text_primary
|
||||
))
|
||||
qpal.setColor(QPalette.Text, QColor(palette.text_primary))
|
||||
qpal.setColor(QPalette.Button, QColor(palette.btn_bg))
|
||||
qpal.setColor(QPalette.ButtonText, QColor(palette.text_primary))
|
||||
qpal.setColor(QPalette.Highlight, QColor(palette.accent))
|
||||
qpal.setColor(QPalette.HighlightedText, QColor(palette.text_on_accent))
|
||||
return qpal
|
||||
|
||||
|
||||
def build_stylesheet(palette: ThemePalette) -> str:
|
||||
d = asdict(palette)
|
||||
# Генерируем SVG-стрелки
|
||||
|
||||
@ -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_required_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()
|
||||
@ -269,6 +276,7 @@ class ConnectionTab(QWidget):
|
||||
self._button_backlight_section = ButtonBacklightSection(self._connection)
|
||||
self._input_section = InputTestSection(self._connection)
|
||||
self._payment_section = PaymentSection(
|
||||
connection=self._connection,
|
||||
send_raw=lambda cmd: self._input_section._send_raw(cmd),
|
||||
)
|
||||
|
||||
@ -310,6 +318,7 @@ class ConnectionTab(QWidget):
|
||||
("Тест силовых выходов", "fa5s.bolt", self._open_output_test),
|
||||
("Обновление прошивки", "fa5s.upload", self._open_firmware),
|
||||
("Задание роли", "fa5s.user-tag", self._open_role),
|
||||
("Витрина призов", "fa5s.gift", self._open_prize_showcase),
|
||||
)
|
||||
self._pro_buttons: list[tuple[QPushButton, str]] = []
|
||||
for title, icon_name, handler in pro_items:
|
||||
@ -360,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(
|
||||
@ -391,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()
|
||||
@ -431,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
|
||||
@ -446,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()
|
||||
@ -483,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()
|
||||
|
||||
@ -499,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()
|
||||
|
||||
# ── Открытие диалогов ───────────────────────────────────────────────
|
||||
|
||||
@ -605,13 +621,45 @@ class ConnectionTab(QWidget):
|
||||
lambda: RoleDialog(self._connection, parent=self),
|
||||
)
|
||||
|
||||
def _open_prize_showcase(self) -> None:
|
||||
from .dialogs.prize_showcase_dialog import PrizeShowcaseDialog
|
||||
self._exec_dialog(
|
||||
"prize_showcase",
|
||||
lambda: PrizeShowcaseDialog(self._connection, parent=self),
|
||||
)
|
||||
|
||||
def _open_terminal(self) -> None:
|
||||
from .dialogs.command_terminal_dialog import CommandTerminalDialog
|
||||
|
||||
def create_terminal() -> CommandTerminalDialog:
|
||||
dialog = CommandTerminalDialog(self._connection, parent=self)
|
||||
dialog.authorization_finished.connect(self._on_terminal_authorization)
|
||||
return dialog
|
||||
|
||||
self._exec_dialog(
|
||||
"terminal",
|
||||
lambda: CommandTerminalDialog(self._connection, parent=self),
|
||||
create_terminal,
|
||||
)
|
||||
|
||||
def _on_terminal_authorization(self, result: AuthResult) -> None:
|
||||
if result.status == AuthStatus.OK:
|
||||
self._update_plate_state(PlateState.READY)
|
||||
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()
|
||||
return
|
||||
|
||||
state = (
|
||||
PlateState.NEED_AUTH
|
||||
if self._connection.is_connected
|
||||
else PlateState.DISCONNECTED
|
||||
)
|
||||
self._input_section.stop_polling()
|
||||
self._set_board_actions_enabled(False)
|
||||
self._update_plate_state(state)
|
||||
|
||||
def _not_implemented(self) -> None:
|
||||
QMessageBox.information(self, "В разработке", "Эта функция пока не реализована.")
|
||||
|
||||
@ -621,6 +669,8 @@ class ConnectionTab(QWidget):
|
||||
PlateState.NEED_AUTH: "Требуется авторизация",
|
||||
PlateState.READY: "Готово",
|
||||
}
|
||||
if state == PlateState.READY and self._connection.auth_required is False:
|
||||
_STATE_LABELS[PlateState.READY] = "Готово без авторизации"
|
||||
_STATE_CSS = {
|
||||
PlateState.DISCONNECTED: "disconnected",
|
||||
PlateState.NEED_AUTH: "need-auth",
|
||||
@ -665,54 +715,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,
|
||||
"Авторизация",
|
||||
@ -785,13 +860,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:
|
||||
@ -799,6 +879,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()
|
||||
|
||||
@ -817,25 +899,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)
|
||||
@ -862,21 +931,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_required_handshake(self._conn, timeout=2.0)
|
||||
self.result_ready.emit(result)
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import QThread, Qt, Signal
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
@ -13,17 +13,28 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QSplitter,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...ui.icons import get_icon_muted, icon
|
||||
from ...services.auth_service import (
|
||||
AuthResult,
|
||||
AuthStatus,
|
||||
perform_required_handshake,
|
||||
)
|
||||
from ...ui.icons import get_icon_color, get_icon_muted, icon
|
||||
from .styled_dialog import StyledDialog
|
||||
|
||||
|
||||
class CommandTerminalDialog(StyledDialog):
|
||||
authorization_finished = Signal(object)
|
||||
|
||||
def __init__(self, connection: ComConnection, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._conn = connection
|
||||
self._auth_thread: _TerminalHandshakeThread | None = None
|
||||
self._command_controls: list[QWidget] = []
|
||||
self.setWindowTitle("Ввод команд")
|
||||
self.setMinimumSize(540, 400)
|
||||
|
||||
@ -48,13 +59,22 @@ class CommandTerminalDialog(StyledDialog):
|
||||
root = self.containerLayout()
|
||||
root.setSpacing(8)
|
||||
|
||||
# Вертикальный сплиттер для изменения высоты лога
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# ── Лог ──
|
||||
self._log = QTextEdit()
|
||||
self._log.setReadOnly(True)
|
||||
self._log.setStyleSheet(
|
||||
"QTextEdit { background: #1e293b; color: #e2e8f0; font-family: 'Consolas', 'Courier New', monospace; font-size: 12px; border-radius: 8px; padding: 8px; }"
|
||||
)
|
||||
root.addWidget(self._log, 1)
|
||||
splitter.addWidget(self._log)
|
||||
|
||||
# ── Нижняя панель управления ──
|
||||
bottom_widget = QWidget()
|
||||
bottom_layout = QVBoxLayout(bottom_widget)
|
||||
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
||||
bottom_layout.setSpacing(8)
|
||||
|
||||
# ── Ввод ──
|
||||
input_layout = QHBoxLayout()
|
||||
@ -63,21 +83,27 @@ class CommandTerminalDialog(StyledDialog):
|
||||
self._input.returnPressed.connect(self._send_command)
|
||||
input_layout.addWidget(self._input, 1)
|
||||
|
||||
send_btn = QPushButton("Отправить")
|
||||
send_btn.setProperty("role", "primary")
|
||||
send_btn.setIcon(icon("fa5s.paper-plane", color="#ecfdf5"))
|
||||
send_btn.clicked.connect(self._send_command)
|
||||
input_layout.addWidget(send_btn)
|
||||
self._send_btn = QPushButton("Отправить")
|
||||
self._send_btn.setProperty("role", "primary")
|
||||
self._send_btn.setIcon(icon("fa5s.paper-plane", color="#ecfdf5"))
|
||||
self._send_btn.clicked.connect(self._send_command)
|
||||
input_layout.addWidget(self._send_btn)
|
||||
|
||||
clear_btn = QPushButton("Очистить")
|
||||
clear_btn.setIcon(icon("fa5s.eraser", color=get_icon_muted()))
|
||||
clear_btn.clicked.connect(self._log.clear)
|
||||
input_layout.addWidget(clear_btn)
|
||||
|
||||
root.addLayout(input_layout)
|
||||
bottom_layout.addLayout(input_layout)
|
||||
|
||||
# ── Быстрые команды ──
|
||||
quick_layout = QHBoxLayout()
|
||||
self._auth_btn = QPushButton("Авторизация")
|
||||
self._auth_btn.setMinimumHeight(30)
|
||||
self._auth_btn.setIcon(icon("fa5s.key", color=get_icon_color()))
|
||||
self._auth_btn.clicked.connect(self._start_authorization)
|
||||
quick_layout.addWidget(self._auth_btn)
|
||||
|
||||
quick_commands = [
|
||||
("Статус (99)", "99"),
|
||||
("Версия (05)", "05"),
|
||||
@ -91,8 +117,9 @@ class CommandTerminalDialog(StyledDialog):
|
||||
btn.setStyleSheet("padding: 4px 10px;")
|
||||
btn.clicked.connect(lambda checked=False, c=cmd: self._send(c))
|
||||
quick_layout.addWidget(btn)
|
||||
self._command_controls.append(btn)
|
||||
quick_layout.addStretch()
|
||||
root.addLayout(quick_layout)
|
||||
bottom_layout.addLayout(quick_layout)
|
||||
|
||||
# ── Закрыть ──
|
||||
btn_layout = QHBoxLayout()
|
||||
@ -101,7 +128,63 @@ class CommandTerminalDialog(StyledDialog):
|
||||
close_btn.setIcon(icon("fa5s.times", color=get_icon_muted()))
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_layout.addWidget(close_btn)
|
||||
root.addLayout(btn_layout)
|
||||
bottom_layout.addLayout(btn_layout)
|
||||
|
||||
splitter.addWidget(bottom_widget)
|
||||
|
||||
# Начальное распределение высоты (75% на лог, 25% на нижнюю панель)
|
||||
splitter.setStretchFactor(0, 75)
|
||||
splitter.setStretchFactor(1, 25)
|
||||
|
||||
root.addWidget(splitter, 1)
|
||||
self._command_controls.extend((self._input, self._send_btn, self._auth_btn))
|
||||
|
||||
def _start_authorization(self) -> None:
|
||||
if not self._conn.is_connected:
|
||||
self._log.append('<span style="color:#f87171;">✗ Порт не подключён</span>')
|
||||
return
|
||||
if self._auth_thread is not None:
|
||||
return
|
||||
|
||||
self._set_command_controls_enabled(False)
|
||||
self._auth_btn.setText("Авторизация…")
|
||||
self._log.append('<span style="color:#facc15;">Проверка авторизации…</span>')
|
||||
self._conn.pause_drain()
|
||||
|
||||
thread = _TerminalHandshakeThread(self._conn, parent=self)
|
||||
thread.result_ready.connect(self._on_authorization_done)
|
||||
thread.finished.connect(self._cleanup_auth_thread)
|
||||
self._auth_thread = thread
|
||||
thread.start()
|
||||
|
||||
def _on_authorization_done(self, result: AuthResult) -> None:
|
||||
self._conn.resume_drain()
|
||||
self._set_command_controls_enabled(True)
|
||||
self._auth_btn.setText("Авторизация")
|
||||
if result.status == AuthStatus.OK:
|
||||
if result.auth_skipped:
|
||||
self._log.append(
|
||||
'<span style="color:#4ade80;">✓ Прошивка no_auth: '
|
||||
'авторизация не требуется</span>'
|
||||
)
|
||||
else:
|
||||
self._log.append(
|
||||
'<span style="color:#4ade80;">✓ Авторизация успешно завершена</span>'
|
||||
)
|
||||
else:
|
||||
self._log.append(
|
||||
f'<span style="color:#f87171;">✗ Авторизация: {result.detail}</span>'
|
||||
)
|
||||
self.authorization_finished.emit(result)
|
||||
|
||||
def _cleanup_auth_thread(self) -> None:
|
||||
if self._auth_thread is not None:
|
||||
self._auth_thread.deleteLater()
|
||||
self._auth_thread = None
|
||||
|
||||
def _set_command_controls_enabled(self, enabled: bool) -> None:
|
||||
for control in self._command_controls:
|
||||
control.setEnabled(enabled)
|
||||
|
||||
def _send_command(self) -> None:
|
||||
text = self._input.text().strip()
|
||||
@ -167,8 +250,29 @@ class CommandTerminalDialog(StyledDialog):
|
||||
self._log.append(f'<span style="color:#93c5fd;">← {stripped}</span>')
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
if self._auth_thread is not None and self._auth_thread.isRunning():
|
||||
self._log.append(
|
||||
'<span style="color:#facc15;">Дождитесь завершения авторизации.</span>'
|
||||
)
|
||||
event.ignore()
|
||||
return
|
||||
try:
|
||||
self._conn.line_received.disconnect(self._on_line_received)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
class _TerminalHandshakeThread(QThread):
|
||||
result_ready = Signal(object)
|
||||
|
||||
def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._conn = connection
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = perform_required_handshake(self._conn, timeout=2.0)
|
||||
except Exception as exc:
|
||||
result = AuthResult(AuthStatus.FAILED, str(exc))
|
||||
self.result_ready.emit(result)
|
||||
|
||||
@ -31,9 +31,12 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QSplitter,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...services.firmware_service import perform_authenticated_ota
|
||||
from ...ui.icons import get_icon_color, get_icon_muted, icon
|
||||
from .styled_dialog import StyledDialog
|
||||
|
||||
@ -55,6 +58,15 @@ class FirmwareDialog(StyledDialog):
|
||||
root = self.containerLayout()
|
||||
root.setSpacing(12)
|
||||
|
||||
# Вертикальный сплиттер для изменения высоты лога
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# ── Верхняя панель (информация + загрузка) ──
|
||||
top_widget = QWidget()
|
||||
top_layout = QVBoxLayout(top_widget)
|
||||
top_layout.setContentsMargins(0, 0, 0, 0)
|
||||
top_layout.setSpacing(12)
|
||||
|
||||
# ── Информация о плате ──
|
||||
info_group = QGroupBox("Информация о плате")
|
||||
info_layout = QVBoxLayout(info_group)
|
||||
@ -82,7 +94,7 @@ class FirmwareDialog(StyledDialog):
|
||||
self._info_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
info_layout.addWidget(self._info_label)
|
||||
|
||||
root.addWidget(info_group)
|
||||
top_layout.addWidget(info_group)
|
||||
|
||||
# ── Загрузка файла ──
|
||||
upload_group = QGroupBox("Загрузка прошивки")
|
||||
@ -117,13 +129,19 @@ class FirmwareDialog(StyledDialog):
|
||||
upload_btn_layout.addWidget(self._flash_btn)
|
||||
upload_layout.addLayout(upload_btn_layout)
|
||||
|
||||
root.addWidget(upload_group)
|
||||
top_layout.addWidget(upload_group)
|
||||
splitter.addWidget(top_widget)
|
||||
|
||||
# ── Лог ──
|
||||
# ── Лог (нижняя часть сплиттера) ──
|
||||
self._log = QTextEdit()
|
||||
self._log.setReadOnly(True)
|
||||
self._log.setMaximumHeight(200)
|
||||
root.addWidget(self._log)
|
||||
splitter.addWidget(self._log)
|
||||
|
||||
# Начальное распределение размеров (65% на верхнюю часть, 35% на лог)
|
||||
splitter.setStretchFactor(0, 65)
|
||||
splitter.setStretchFactor(1, 35)
|
||||
|
||||
root.addWidget(splitter, 1)
|
||||
|
||||
# ── Закрыть ──
|
||||
btn_layout = QHBoxLayout()
|
||||
@ -220,10 +238,27 @@ class FirmwareDialog(StyledDialog):
|
||||
self._log.append("✗ Загрузка не удалась.")
|
||||
|
||||
def _flash_ota(self) -> None:
|
||||
resp = self._query("04")
|
||||
self._log.append(f"→ 04\n← {resp or 'таймаут'}")
|
||||
if resp:
|
||||
QMessageBox.information(self, "OTA", f"Ответ контроллера:\n{resp}")
|
||||
if not self._conn.is_connected:
|
||||
self._log.append("✗ Порт не подключён.")
|
||||
return
|
||||
|
||||
self._flash_btn.setEnabled(False)
|
||||
self._upload_btn.setEnabled(False)
|
||||
self._log.append("Запуск OTA (04)…")
|
||||
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
|
||||
def append_log(message: str) -> None:
|
||||
self._log.append(message)
|
||||
QCoreApplication.processEvents()
|
||||
|
||||
result = perform_authenticated_ota(self._conn, log_message=append_log)
|
||||
if result.success:
|
||||
self._log.append(f"✓ {result.detail}.")
|
||||
else:
|
||||
self._log.append(f"✗ {result.detail}")
|
||||
self._flash_btn.setEnabled(True)
|
||||
self._upload_btn.setEnabled(True)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
if self._upload_thread is not None and self._upload_thread.isRunning():
|
||||
|
||||
@ -15,6 +15,8 @@ import sys
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@ -23,6 +25,7 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
@ -142,34 +145,243 @@ class LedTestDialog(StyledDialog):
|
||||
effect_form.addRow(btn_row)
|
||||
root.addWidget(effect_group)
|
||||
|
||||
# ── Цветовое чередование ──
|
||||
color_group = QGroupBox("Цветовое чередование (cmd 89)")
|
||||
color_root = QVBoxLayout(color_group)
|
||||
# ── Настройки WS2812 (cmd 89) ──
|
||||
color_group = QGroupBox("Настройки WS2812 (cmd 89)")
|
||||
color_form = QFormLayout(color_group)
|
||||
color_form.setSpacing(6)
|
||||
|
||||
# Выбор физического канала
|
||||
self._cmd89_channel_combo = NoWheelComboBox()
|
||||
self._cmd89_channel_combo.addItem("Все ленты (0)", 0)
|
||||
self._cmd89_channel_combo.addItem("2-й канал (1)", 1)
|
||||
self._cmd89_channel_combo.addItem("3-й канал (2)", 2)
|
||||
self._cmd89_channel_combo.addItem("4-й канал (3)", 3)
|
||||
color_form.addRow("Физический канал:", self._cmd89_channel_combo)
|
||||
|
||||
# Порядок цветов
|
||||
self._color_order_combo = NoWheelComboBox()
|
||||
for code, name in _COLOR_ORDERS:
|
||||
self._color_order_combo.addItem(f"{code} — {name}", code)
|
||||
color_root.addWidget(self._color_order_combo)
|
||||
|
||||
set_color_btn = QPushButton("1. Применить")
|
||||
set_color_btn.setProperty("role", "primary")
|
||||
set_color_btn.setIcon(icon("fa5s.palette", color="#ecfdf5"))
|
||||
set_color_btn.clicked.connect(self._send_color_order)
|
||||
color_root.addWidget(set_color_btn)
|
||||
apply_order_btn = QPushButton("Применить порядок")
|
||||
apply_order_btn.setProperty("role", "primary")
|
||||
apply_order_btn.setIcon(icon("fa5s.palette", color="#ecfdf5"))
|
||||
apply_order_btn.clicked.connect(self._send_color_order)
|
||||
|
||||
self._color_test_btn = QPushButton("2. Тест цвета")
|
||||
order_layout = QHBoxLayout()
|
||||
order_layout.addWidget(self._color_order_combo, 1)
|
||||
order_layout.addWidget(apply_order_btn)
|
||||
color_form.addRow("Порядок цветов:", order_layout)
|
||||
|
||||
# Яркость
|
||||
self._cmd89_brightness_spin = QSpinBox()
|
||||
self._cmd89_brightness_spin.setRange(0, 255)
|
||||
self._cmd89_brightness_spin.setValue(255)
|
||||
|
||||
apply_bright_btn = QPushButton("Применить яркость")
|
||||
apply_bright_btn.setIcon(icon("fa5s.sun", color=get_icon_color()))
|
||||
apply_bright_btn.clicked.connect(self._send_brightness)
|
||||
|
||||
bright_layout = QHBoxLayout()
|
||||
bright_layout.addWidget(self._cmd89_brightness_spin, 1)
|
||||
bright_layout.addWidget(apply_bright_btn)
|
||||
color_form.addRow("Яркость:", bright_layout)
|
||||
|
||||
# Гамма-коррекция
|
||||
self._cmd89_gamma_check = QCheckBox("Включить гамма-коррекцию")
|
||||
|
||||
apply_gamma_btn = QPushButton("Применить гамму")
|
||||
apply_gamma_btn.setIcon(icon("fa5s.adjust", color=get_icon_color()))
|
||||
apply_gamma_btn.clicked.connect(self._send_gamma)
|
||||
|
||||
gamma_layout = QHBoxLayout()
|
||||
gamma_layout.addWidget(self._cmd89_gamma_check, 1)
|
||||
gamma_layout.addWidget(apply_gamma_btn)
|
||||
color_form.addRow("Гамма-коррекция:", gamma_layout)
|
||||
|
||||
# Пользовательский цвет
|
||||
self._custom_color_btn = QPushButton("Выбрать и отправить цвет")
|
||||
self._custom_color_btn.setIcon(icon("fa5s.palette", color=get_icon_color()))
|
||||
self._custom_color_btn.clicked.connect(self._send_custom_color)
|
||||
color_form.addRow("Свой цвет (08):", self._custom_color_btn)
|
||||
|
||||
# Тест цвета
|
||||
self._color_test_btn = QPushButton("Тест цвета")
|
||||
self._color_test_btn.setProperty("role", "primary")
|
||||
self._color_test_btn.setIcon(icon("fa5s.eye", color="#ecfdf5"))
|
||||
self._color_test_btn.clicked.connect(self._run_color_test)
|
||||
color_root.addWidget(self._color_test_btn)
|
||||
color_form.addRow(self._color_test_btn)
|
||||
|
||||
color_hint = QLabel("Должны вывестись: красный → зелёный → синий")
|
||||
color_hint.setStyleSheet("color: #9ca3af; font-size: 12px;")
|
||||
color_hint.setStyleSheet("color: #9ca3af; font-size: 11px;")
|
||||
color_hint.setWordWrap(True)
|
||||
color_root.addWidget(color_hint)
|
||||
color_form.addRow(color_hint)
|
||||
|
||||
root.addWidget(color_group)
|
||||
|
||||
# ── Бродяги морей (cmd 81) ──
|
||||
wanderer_group = QGroupBox("Бродяги морей (cmd 81)")
|
||||
wanderer_form = QFormLayout(wanderer_group)
|
||||
wanderer_form.setSpacing(6)
|
||||
|
||||
# Объект 1
|
||||
self._w1_level = QSpinBox()
|
||||
self._w1_level.setRange(0, 21)
|
||||
self._w1_level.setValue(5)
|
||||
|
||||
w1_apply_level = QPushButton("Задать уровень")
|
||||
w1_apply_level.setIcon(icon("fa5s.angle-double-right", color=get_icon_color()))
|
||||
w1_apply_level.clicked.connect(self._send_w1_level)
|
||||
|
||||
w1_level_layout = QHBoxLayout()
|
||||
w1_level_layout.addWidget(self._w1_level, 1)
|
||||
w1_level_layout.addWidget(w1_apply_level)
|
||||
wanderer_form.addRow("Шкала 1 (уровень 0..21):", w1_level_layout)
|
||||
|
||||
self._w1_effect = NoWheelComboBox()
|
||||
self._w1_effect.addItem("Эффект 1 (синий/фиолетовый)", 1)
|
||||
self._w1_effect.addItem("Эффект 2 (фиолетовый/голубой)", 2)
|
||||
self._w1_effect.addItem("Эффект 3 (мигание белым)", 3)
|
||||
|
||||
w1_apply_eff = QPushButton("Запустить эффект")
|
||||
w1_apply_eff.setProperty("role", "primary")
|
||||
w1_apply_eff.setIcon(icon("fa5s.play", color="#ecfdf5"))
|
||||
w1_apply_eff.clicked.connect(self._send_w1_effect)
|
||||
|
||||
w1_eff_layout = QHBoxLayout()
|
||||
w1_eff_layout.addWidget(self._w1_effect, 1)
|
||||
w1_eff_layout.addWidget(w1_apply_eff)
|
||||
wanderer_form.addRow("Эффекты шкалы 1:", w1_eff_layout)
|
||||
|
||||
# Объект 2
|
||||
self._w2_level = QSpinBox()
|
||||
self._w2_level.setRange(0, 44)
|
||||
self._w2_level.setValue(5)
|
||||
|
||||
w2_apply_level = QPushButton("Задать уровень")
|
||||
w2_apply_level.setIcon(icon("fa5s.angle-double-right", color=get_icon_color()))
|
||||
w2_apply_level.clicked.connect(self._send_w2_level)
|
||||
|
||||
w2_level_layout = QHBoxLayout()
|
||||
w2_level_layout.addWidget(self._w2_level, 1)
|
||||
w2_level_layout.addWidget(w2_apply_level)
|
||||
wanderer_form.addRow("Шкала 2 (уровень 0..44):", w2_level_layout)
|
||||
|
||||
self._w2_effect = NoWheelComboBox()
|
||||
self._w2_effect.addItem("Эффект 1 (синий/фиолетовый)", 1)
|
||||
self._w2_effect.addItem("Эффект 2 (фиолетовый/голубой)", 2)
|
||||
self._w2_effect.addItem("Эффект 3 (мигание белым)", 3)
|
||||
|
||||
w2_apply_eff = QPushButton("Запустить эффект")
|
||||
w2_apply_eff.setProperty("role", "primary")
|
||||
w2_apply_eff.setIcon(icon("fa5s.play", color="#ecfdf5"))
|
||||
w2_apply_eff.clicked.connect(self._send_w2_effect)
|
||||
|
||||
w2_eff_layout = QHBoxLayout()
|
||||
w2_eff_layout.addWidget(self._w2_effect, 1)
|
||||
w2_eff_layout.addWidget(w2_apply_eff)
|
||||
wanderer_form.addRow("Эффекты шкалы 2:", w2_eff_layout)
|
||||
|
||||
# Сброс обоих
|
||||
w_clear_btn = QPushButton("Очистить обе шкалы (8130)")
|
||||
w_clear_btn.setIcon(icon("fa5s.eraser", color=get_icon_color()))
|
||||
w_clear_btn.clicked.connect(self._clear_wanderer)
|
||||
wanderer_form.addRow(w_clear_btn)
|
||||
|
||||
root.addWidget(wanderer_group)
|
||||
|
||||
# ── Управление виртуальной матрицей (cmd 85/86/87) ──
|
||||
matrix_group = QGroupBox("Виртуальная матрица (cmd 85/86/87)")
|
||||
matrix_form = QFormLayout(matrix_group)
|
||||
matrix_form.setSpacing(6)
|
||||
|
||||
# Автокарусель / Рендеринг
|
||||
carousel_layout = QHBoxLayout()
|
||||
|
||||
self._mc_on_btn = QPushButton("Карусель Вкл")
|
||||
self._mc_on_btn.setIcon(icon("fa5s.play-circle", color=get_icon_color()))
|
||||
self._mc_on_btn.clicked.connect(self._send_carousel_on)
|
||||
carousel_layout.addWidget(self._mc_on_btn)
|
||||
|
||||
self._mc_off_btn = QPushButton("Карусель Выкл")
|
||||
self._mc_off_btn.setIcon(icon("fa5s.stop-circle", color=get_icon_color()))
|
||||
self._mc_off_btn.clicked.connect(self._send_carousel_off)
|
||||
carousel_layout.addWidget(self._mc_off_btn)
|
||||
|
||||
self._mc_stop_btn = QPushButton("Rendering OFF")
|
||||
self._mc_stop_btn.setProperty("role", "primary")
|
||||
self._mc_stop_btn.setIcon(icon("fa5s.power-off", color="#ecfdf5"))
|
||||
self._mc_stop_btn.clicked.connect(self._send_rendering_off)
|
||||
carousel_layout.addWidget(self._mc_stop_btn)
|
||||
|
||||
matrix_form.addRow("Режимы рендера:", carousel_layout)
|
||||
|
||||
# Тесты матриц (8525 / 8625 / 8725)
|
||||
test_type_layout = QHBoxLayout()
|
||||
self._m_test_type = NoWheelComboBox()
|
||||
self._m_test_type.addItem("Выключить тест (0)", 0)
|
||||
self._m_test_type.addItem("Общий тест / Змейка (1)", 1)
|
||||
self._m_test_type.addItem("Тест строки (2)", 2)
|
||||
self._m_test_type.addItem("Тест столбца (3)", 3)
|
||||
self._m_test_type.addItem("Тест пикселя (4)", 4)
|
||||
self._m_test_type.currentIndexChanged.connect(self._on_matrix_test_type_changed)
|
||||
test_type_layout.addWidget(self._m_test_type, 1)
|
||||
|
||||
self._m_strip_label = QLabel("Лента:")
|
||||
self._m_strip_label.setVisible(False)
|
||||
test_type_layout.addWidget(self._m_strip_label)
|
||||
|
||||
self._m_strip_spin = QSpinBox()
|
||||
self._m_strip_spin.setRange(1, 4)
|
||||
self._m_strip_spin.setValue(1)
|
||||
self._m_strip_spin.setVisible(False)
|
||||
test_type_layout.addWidget(self._m_strip_spin)
|
||||
|
||||
self._m_test_param = QSpinBox()
|
||||
self._m_test_param.setRange(0, 999)
|
||||
self._m_test_param.setValue(0)
|
||||
self._m_test_param.setVisible(False)
|
||||
test_type_layout.addWidget(self._m_test_param)
|
||||
|
||||
m_test_btn = QPushButton("Запустить")
|
||||
m_test_btn.setIcon(icon("fa5s.vial", color=get_icon_color()))
|
||||
m_test_btn.clicked.connect(self._send_matrix_test)
|
||||
test_type_layout.addWidget(m_test_btn)
|
||||
|
||||
matrix_form.addRow("Аппаратный тест:", test_type_layout)
|
||||
|
||||
# Direct-шкалы (только для s_b)
|
||||
self._scales_container = QWidget()
|
||||
scales_layout = QVBoxLayout(self._scales_container)
|
||||
scales_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scales_layout.setSpacing(4)
|
||||
|
||||
scales_title = QLabel("<b>Direct-шкалы (только для Морского боя s_b)</b>")
|
||||
scales_layout.addWidget(scales_title)
|
||||
|
||||
self._m_scales: list[QSpinBox] = []
|
||||
for i in range(4):
|
||||
scale_row = QHBoxLayout()
|
||||
spin = QSpinBox()
|
||||
spin.setRange(0, 22)
|
||||
spin.setValue(0)
|
||||
self._m_scales.append(spin)
|
||||
scale_row.addWidget(spin, 1)
|
||||
|
||||
btn = QPushButton(f"Задать шкалу {i+1}")
|
||||
btn.setIcon(icon("fa5s.level-up-alt", color=get_icon_color()))
|
||||
btn.clicked.connect(lambda checked=False, idx=i: self._send_matrix_scale(idx))
|
||||
scale_row.addWidget(btn)
|
||||
scales_layout.addLayout(scale_row)
|
||||
|
||||
clear_scales_btn = QPushButton("Очистить direct-шкалы (8530)")
|
||||
clear_scales_btn.setIcon(icon("fa5s.trash-alt", color=get_icon_color()))
|
||||
clear_scales_btn.clicked.connect(self._clear_matrix_scales)
|
||||
scales_layout.addWidget(clear_scales_btn)
|
||||
|
||||
matrix_form.addRow(self._scales_container)
|
||||
root.addWidget(matrix_group)
|
||||
|
||||
# ── Лог ──
|
||||
self._log = QLabel("")
|
||||
self._log.setObjectName("LogLabel")
|
||||
@ -215,6 +427,10 @@ class LedTestDialog(StyledDialog):
|
||||
# Кнопка «выключить» — только для has_duration
|
||||
self._stop_btn.setVisible(mode.has_duration)
|
||||
|
||||
# Показывать direct-шкалы только для Морского боя s_b (где base_command == "85")
|
||||
is_sb = (mode.base_command == "85")
|
||||
self._scales_container.setVisible(is_sb)
|
||||
|
||||
def _reload_modes(self) -> None:
|
||||
"""Перечитать .lm файлы из папки."""
|
||||
current_name = self._project_combo.currentText()
|
||||
@ -279,10 +495,33 @@ class LedTestDialog(StyledDialog):
|
||||
self._send(mode.base_command)
|
||||
|
||||
def _send_color_order(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
code = self._color_order_combo.currentData()
|
||||
cmd = f"8900{code}"
|
||||
cmd = f"89{ch}0{code}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_brightness(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
val = self._cmd89_brightness_spin.value()
|
||||
cmd = f"89{ch}1{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_gamma(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
val = 1 if self._cmd89_gamma_check.isChecked() else 0
|
||||
cmd = f"89{ch}2{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_custom_color(self) -> None:
|
||||
color = QColorDialog.getColor(Qt.GlobalColor.white, self, "Выберите цвет")
|
||||
if color.isValid():
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
r = color.red()
|
||||
g = color.green()
|
||||
b = color.blue()
|
||||
cmd = f"8{ch}08{r:03d}{g:03d}{b:03d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _run_color_test(self) -> None:
|
||||
"""Последовательно отправить 8003→8004→8005→8002→8000 с паузой 300 мс."""
|
||||
self._color_test_cmds = ["8003", "8004", "8005", "8002", "8000"]
|
||||
@ -304,6 +543,75 @@ class LedTestDialog(StyledDialog):
|
||||
self._color_test_idx += 1
|
||||
self._send(cmd)
|
||||
|
||||
def _send_w1_level(self) -> None:
|
||||
val = self._w1_level.value()
|
||||
cmd = f"8131{val:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_w1_effect(self) -> None:
|
||||
val = self._w1_effect.currentData()
|
||||
cmd = f"8131{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_w2_level(self) -> None:
|
||||
val = self._w2_level.value()
|
||||
cmd = f"8132{val:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_w2_effect(self) -> None:
|
||||
val = self._w2_effect.currentData()
|
||||
cmd = f"8132{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _clear_wanderer(self) -> None:
|
||||
self._send("8130")
|
||||
|
||||
def _send_carousel_on(self) -> None:
|
||||
mode = self._current_mode()
|
||||
if mode:
|
||||
self._send(f"{mode.base_command}01")
|
||||
|
||||
def _send_carousel_off(self) -> None:
|
||||
mode = self._current_mode()
|
||||
if mode:
|
||||
self._send(f"{mode.base_command}00")
|
||||
|
||||
def _send_rendering_off(self) -> None:
|
||||
mode = self._current_mode()
|
||||
if mode:
|
||||
self._send(mode.base_command)
|
||||
|
||||
def _on_matrix_test_type_changed(self, index: int) -> None:
|
||||
test_type = self._m_test_type.itemData(index)
|
||||
is_pixel_test = (test_type == 4)
|
||||
self._m_strip_label.setVisible(is_pixel_test)
|
||||
self._m_strip_spin.setVisible(is_pixel_test)
|
||||
self._m_test_param.setVisible(test_type >= 2)
|
||||
|
||||
def _send_matrix_test(self) -> None:
|
||||
mode = self._current_mode()
|
||||
if not mode:
|
||||
return
|
||||
test_type = self._m_test_type.currentData()
|
||||
if test_type == 0 or test_type == 1:
|
||||
cmd = f"{mode.base_command}25{test_type}"
|
||||
elif test_type == 2 or test_type == 3:
|
||||
val = self._m_test_param.value()
|
||||
cmd = f"{mode.base_command}25{test_type}{val:02d}"
|
||||
elif test_type == 4:
|
||||
strip = self._m_strip_spin.value()
|
||||
pixel = self._m_test_param.value()
|
||||
cmd = f"{mode.base_command}254{strip}{pixel:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_matrix_scale(self, index: int) -> None:
|
||||
val = self._m_scales[index].value()
|
||||
cmd = f"853{index+1}{val:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _clear_matrix_scales(self) -> None:
|
||||
self._send("8530")
|
||||
|
||||
def _send(self, cmd: str) -> None:
|
||||
if not cmd:
|
||||
return
|
||||
|
||||
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@ -22,6 +23,7 @@ from ...services.com_service import ComConnection
|
||||
from ...services import settings_service
|
||||
from ...ui.icons import get_icon_color, get_icon_muted, icon
|
||||
from ...ui.theme import get_palette
|
||||
from ..no_wheel_combo_box import NoWheelComboBox
|
||||
from .styled_dialog import StyledDialog
|
||||
|
||||
_NUM_OUTPUTS = 8
|
||||
@ -122,6 +124,67 @@ class OutputTestDialog(StyledDialog):
|
||||
|
||||
root.addLayout(bulk_layout)
|
||||
|
||||
# ── Дополнительные GPIO25/GPIO26 (cmd 50) ──
|
||||
gpio_group = QGroupBox("Дополнительные GPIO25/GPIO26 (cmd 50)")
|
||||
gpio_form = QFormLayout(gpio_group)
|
||||
gpio_form.setSpacing(6)
|
||||
|
||||
# Выбор режимов
|
||||
self._gpio25_mode = NoWheelComboBox()
|
||||
self._gpio25_mode.addItem("Вход (Input)", 0)
|
||||
self._gpio25_mode.addItem("Выход (Output)", 1)
|
||||
|
||||
self._gpio26_mode = NoWheelComboBox()
|
||||
self._gpio26_mode.addItem("Вход (Input)", 0)
|
||||
self._gpio26_mode.addItem("Выход (Output)", 1)
|
||||
|
||||
mode_layout = QHBoxLayout()
|
||||
mode_layout.addWidget(self._gpio25_mode, 1)
|
||||
mode_layout.addWidget(QLabel("GPIO26:"))
|
||||
mode_layout.addWidget(self._gpio26_mode, 1)
|
||||
|
||||
apply_mode_btn = QPushButton("Задать режим")
|
||||
apply_mode_btn.setIcon(icon("fa5s.cog", color=get_icon_color()))
|
||||
apply_mode_btn.clicked.connect(self._send_gpio_modes)
|
||||
mode_layout.addWidget(apply_mode_btn)
|
||||
gpio_form.addRow("GPIO25 Режим:", mode_layout)
|
||||
|
||||
# Установка уровней
|
||||
self._gpio25_level = NoWheelComboBox()
|
||||
self._gpio25_level.addItem("Низкий (Low)", 0)
|
||||
self._gpio25_level.addItem("Высокий (High)", 1)
|
||||
|
||||
self._gpio26_level = NoWheelComboBox()
|
||||
self._gpio26_level.addItem("Низкий (Low)", 0)
|
||||
self._gpio26_level.addItem("Высокий (High)", 1)
|
||||
|
||||
level_layout = QHBoxLayout()
|
||||
level_layout.addWidget(self._gpio25_level, 1)
|
||||
level_layout.addWidget(QLabel("GPIO26:"))
|
||||
level_layout.addWidget(self._gpio26_level, 1)
|
||||
|
||||
apply_level_btn = QPushButton("Задать уровни")
|
||||
apply_level_btn.setIcon(icon("fa5s.bolt", color=get_icon_color()))
|
||||
apply_level_btn.clicked.connect(self._send_gpio_levels)
|
||||
level_layout.addWidget(apply_level_btn)
|
||||
gpio_form.addRow("GPIO25 Уровень:", level_layout)
|
||||
|
||||
# Запрос состояния
|
||||
status_layout = QHBoxLayout()
|
||||
read_state_btn = QPushButton("Запросить состояние (cmd 50)")
|
||||
read_state_btn.setProperty("role", "primary")
|
||||
read_state_btn.setIcon(icon("fa5s.sync-alt", color="#ecfdf5"))
|
||||
read_state_btn.clicked.connect(self._read_gpio_state)
|
||||
status_layout.addWidget(read_state_btn)
|
||||
|
||||
read_short_btn = QPushButton("Краткое состояние (cmd 5005)")
|
||||
read_short_btn.setIcon(icon("fa5s.info-circle", color=get_icon_color()))
|
||||
read_short_btn.clicked.connect(self._read_gpio_short)
|
||||
status_layout.addWidget(read_short_btn)
|
||||
|
||||
gpio_form.addRow(status_layout)
|
||||
root.addWidget(gpio_group)
|
||||
|
||||
# ── Лог ──
|
||||
self._log = QLabel("")
|
||||
self._log.setObjectName("LogLabel")
|
||||
@ -156,6 +219,26 @@ class OutputTestDialog(StyledDialog):
|
||||
cmd = "200"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_gpio_modes(self) -> None:
|
||||
g25 = self._gpio25_mode.currentData()
|
||||
g26 = self._gpio26_mode.currentData()
|
||||
p_val = g25 * 10 + g26
|
||||
cmd = f"5001{p_val:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_gpio_levels(self) -> None:
|
||||
g25 = self._gpio25_level.currentData()
|
||||
g26 = self._gpio26_level.currentData()
|
||||
p_val = g25 * 10 + g26
|
||||
cmd = f"5010{p_val:02d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _read_gpio_state(self) -> None:
|
||||
self._send("50")
|
||||
|
||||
def _read_gpio_short(self) -> None:
|
||||
self._send("50050")
|
||||
|
||||
def _send(self, cmd: str) -> None:
|
||||
if not cmd:
|
||||
return
|
||||
|
||||
195
app/widgets/dialogs/prize_showcase_dialog.py
Normal file
195
app/widgets/dialogs/prize_showcase_dialog.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""Диалог «Управление призовой витриной» — ячейки призовой стойки (cmd 25).
|
||||
|
||||
Управляет количеством ячеек от 1 до 20 (по умолчанию 5).
|
||||
Команды:
|
||||
- 25: Показать состояние всех ячеек
|
||||
- 2501B: Выдать приз из ячейки B
|
||||
- 2511B: Открыть ячейку B без списания приза
|
||||
- 25991/25990: Включить/выключить автозапуск и перезагрузить
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...ui.icons import get_icon_color, get_icon_muted, icon
|
||||
from .styled_dialog import StyledDialog
|
||||
|
||||
|
||||
class PrizeShowcaseDialog(StyledDialog):
|
||||
"""Диалог управления призовой витриной (cmd 25)."""
|
||||
|
||||
def __init__(self, connection: ComConnection, *, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._conn = connection
|
||||
self.setWindowTitle("Призовая витрина")
|
||||
self.setMinimumWidth(550)
|
||||
self.setMinimumHeight(450)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = self.containerLayout()
|
||||
root.setSpacing(12)
|
||||
|
||||
# ── Описание ──
|
||||
desc = QLabel(
|
||||
"Управление ячейками витрины призов (команда 25).\n"
|
||||
"Поддерживается выдача призов, разблокировка ячеек и управление авторежимом."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #9ca3af; font-size: 12px; margin-bottom: 4px;")
|
||||
root.addWidget(desc)
|
||||
|
||||
# ── Общие настройки авторежима ──
|
||||
global_group = QGroupBox("Общие параметры витрины")
|
||||
global_layout = QHBoxLayout(global_group)
|
||||
|
||||
query_btn = QPushButton("Состояние ячеек (cmd 25)")
|
||||
query_btn.setProperty("role", "primary")
|
||||
query_btn.setIcon(icon("fa5s.sync-alt", color="#ecfdf5"))
|
||||
query_btn.clicked.connect(self._query_state)
|
||||
global_layout.addWidget(query_btn)
|
||||
|
||||
auto_on_btn = QPushButton("Авторежим Вкл")
|
||||
auto_on_btn.setIcon(icon("fa5s.play-circle", color=get_icon_color()))
|
||||
auto_on_btn.clicked.connect(self._send_auto_on)
|
||||
global_layout.addWidget(auto_on_btn)
|
||||
|
||||
auto_off_btn = QPushButton("Авторежим Выкл")
|
||||
auto_off_btn.setIcon(icon("fa5s.stop-circle", color=get_icon_color()))
|
||||
auto_off_btn.clicked.connect(self._send_auto_off)
|
||||
global_layout.addWidget(auto_off_btn)
|
||||
|
||||
root.addWidget(global_group)
|
||||
|
||||
# ── Выбор количества ячеек ──
|
||||
cfg_layout = QHBoxLayout()
|
||||
cfg_layout.addWidget(QLabel("Количество ячеек витрины (1-20):"))
|
||||
self._cells_count_spin = QSpinBox()
|
||||
self._cells_count_spin.setRange(1, 20)
|
||||
self._cells_count_spin.setValue(5)
|
||||
self._cells_count_spin.valueChanged.connect(self._rebuild_grid)
|
||||
cfg_layout.addWidget(self._cells_count_spin)
|
||||
cfg_layout.addStretch()
|
||||
root.addLayout(cfg_layout)
|
||||
|
||||
# ── Область прокрутки для ячеек ──
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
||||
|
||||
self._grid_widget = QWidget()
|
||||
self._grid_layout = QGridLayout(self._grid_widget)
|
||||
self._grid_layout.setSpacing(8)
|
||||
self._grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scroll.setWidget(self._grid_widget)
|
||||
root.addWidget(scroll, 1)
|
||||
|
||||
# ── Лог ──
|
||||
self._log = QLabel("")
|
||||
self._log.setObjectName("LogLabel")
|
||||
self._log.setWordWrap(True)
|
||||
root.addWidget(self._log)
|
||||
|
||||
# ── Закрыть ──
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
close_btn = QPushButton("Закрыть")
|
||||
close_btn.setIcon(icon("fa5s.times", color=get_icon_muted()))
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_layout.addWidget(close_btn)
|
||||
root.addLayout(btn_layout)
|
||||
|
||||
# Создаем сетку ячеек при старте
|
||||
self._rebuild_grid()
|
||||
|
||||
def _rebuild_grid(self) -> None:
|
||||
"""Перестроить сетку ячеек на основе значения spinbox."""
|
||||
# Очищаем старые элементы из макета
|
||||
while self._grid_layout.count():
|
||||
item = self._grid_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
|
||||
count = self._cells_count_spin.value()
|
||||
|
||||
# Заголовки столбцов
|
||||
headers = ["Ячейка", "Выдать приз (01)", "Открыть ячейку (11)"]
|
||||
for col, h in enumerate(headers):
|
||||
lbl = QLabel(f"<b>{h}</b>")
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._grid_layout.addWidget(lbl, 0, col)
|
||||
|
||||
for i in range(count):
|
||||
row = i + 1
|
||||
cell_idx = i + 1
|
||||
|
||||
lbl_cell = QLabel(f"Ячейка {cell_idx}")
|
||||
lbl_cell.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
lbl_cell.setStyleSheet(
|
||||
"background: #1f2937; border: 1px solid #374151; "
|
||||
"border-radius: 4px; padding: 4px; color: #f3f4f6;"
|
||||
)
|
||||
self._grid_layout.addWidget(lbl_cell, row, 0)
|
||||
|
||||
# Выдать приз (2501B)
|
||||
btn_dispense = QPushButton("Выдать")
|
||||
btn_dispense.setProperty("role", "primary")
|
||||
btn_dispense.setIcon(icon("fa5s.gift", color="#ecfdf5"))
|
||||
btn_dispense.clicked.connect(lambda checked=False, idx=cell_idx: self._dispense_prize(idx))
|
||||
self._grid_layout.addWidget(btn_dispense, row, 1)
|
||||
|
||||
# Открыть ячейку (2511B)
|
||||
btn_open = QPushButton("Открыть")
|
||||
btn_open.setIcon(icon("fa5s.unlock", color=get_icon_color()))
|
||||
btn_open.clicked.connect(lambda checked=False, idx=cell_idx: self._open_cell(idx))
|
||||
self._grid_layout.addWidget(btn_open, row, 2)
|
||||
|
||||
# ── Команды витрины ──
|
||||
|
||||
def _query_state(self) -> None:
|
||||
self._send("25")
|
||||
|
||||
def _send_auto_on(self) -> None:
|
||||
self._send("25991")
|
||||
|
||||
def _send_auto_off(self) -> None:
|
||||
self._send("25990")
|
||||
|
||||
def _dispense_prize(self, cell: int) -> None:
|
||||
self._send(f"2501{cell}")
|
||||
|
||||
def _open_cell(self, cell: int) -> None:
|
||||
self._send(f"2511{cell}")
|
||||
|
||||
def _send(self, cmd: str) -> None:
|
||||
if not cmd:
|
||||
return
|
||||
if not self._conn.is_connected:
|
||||
QMessageBox.warning(self, "Ошибка", "Порт не подключён.")
|
||||
return
|
||||
try:
|
||||
self._conn.send_command(cmd)
|
||||
self._log.setText(f"→ {cmd}")
|
||||
resp = self._conn.read_response(timeout=0.4)
|
||||
self._conn.release()
|
||||
if resp:
|
||||
self._log.setText(f"→ {cmd}\n← {resp}")
|
||||
except Exception as exc:
|
||||
self._conn.release()
|
||||
self._log.setText(f"Ошибка: {exc}")
|
||||
@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...ui.icons import get_icon_color, get_icon_muted, icon
|
||||
from ..no_wheel_combo_box import NoWheelComboBox
|
||||
from .styled_dialog import StyledDialog
|
||||
|
||||
|
||||
@ -47,6 +48,22 @@ class RoleDialog(StyledDialog):
|
||||
current_layout.addWidget(read_btn)
|
||||
root.addLayout(current_layout)
|
||||
|
||||
# ── Быстрый выбор роли ──
|
||||
preset_layout = QHBoxLayout()
|
||||
preset_layout.addWidget(QLabel("Шаблоны ролей:"))
|
||||
self._role_preset_combo = NoWheelComboBox()
|
||||
self._role_preset_combo.addItem("— Свой вариант —", "")
|
||||
self._role_preset_combo.addItem("s_b (Морской бой)", "s_b")
|
||||
self._role_preset_combo.addItem("s_b_v1 (Морской бой v1)", "s_b_v1")
|
||||
self._role_preset_combo.addItem("s_b_v2 (Морской бой v2)", "s_b_v2")
|
||||
self._role_preset_combo.addItem("dan_ro (Данро)", "dan_ro")
|
||||
self._role_preset_combo.addItem("dan_ro_v1 (Данро v1)", "dan_ro_v1")
|
||||
self._role_preset_combo.addItem("rz (Резервная панель RZ)", "rz")
|
||||
self._role_preset_combo.addItem("rz_v1 (Резервная панель RZ v1)", "rz_v1")
|
||||
self._role_preset_combo.currentIndexChanged.connect(self._on_preset_changed)
|
||||
preset_layout.addWidget(self._role_preset_combo, 1)
|
||||
root.addLayout(preset_layout)
|
||||
|
||||
# ── Установка новой роли ──
|
||||
set_layout = QHBoxLayout()
|
||||
set_layout.addWidget(QLabel("Новая роль:"))
|
||||
@ -71,6 +88,11 @@ class RoleDialog(StyledDialog):
|
||||
btn_layout.addWidget(close_btn)
|
||||
root.addLayout(btn_layout)
|
||||
|
||||
def _on_preset_changed(self, index: int) -> None:
|
||||
val = self._role_preset_combo.itemData(index)
|
||||
if val:
|
||||
self._role_input.setText(val)
|
||||
|
||||
def _read_role(self) -> None:
|
||||
if not self._conn.is_connected:
|
||||
return
|
||||
|
||||
@ -1,133 +1,68 @@
|
||||
"""Базовый класс для стилизованных диалоговых окон.
|
||||
|
||||
Убирает системную рамку, делает фон прозрачным,
|
||||
добавляет контейнер с зелёной обводкой и скруглёнными углами,
|
||||
мини-заголовок с кнопкой закрытия и перетаскиванием окна.
|
||||
Использует стандартные системные рамки и заголовки, поддерживает
|
||||
изменение размеров, сворачивание и разворачивание диалогов.
|
||||
Внутреннее содержимое оборачивается в прокручиваемую область (QScrollArea),
|
||||
чтобы избежать проблем с выходом окон за границы экрана при низком разрешении.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QGraphicsDropShadowEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QToolButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...ui.icons import ICON_COLOR_DANGER, icon
|
||||
|
||||
|
||||
class StyledDialog(QDialog):
|
||||
"""QDialog без системной рамки, с обводкой, заголовком, кнопкой закрытия и ресайзом."""
|
||||
"""QDialog со стандартной рамкой, прокруткой содержимого и поддержкой изменения размеров."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, **kwargs) -> None:
|
||||
super().__init__(parent, **kwargs)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Dialog
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowMinimizeButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
| Qt.WindowType.WindowCloseButtonHint
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
# Внешний layout
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
# Главный макет диалога
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Внутренний контейнер, к которому применяются стили
|
||||
self._container = QWidget(self)
|
||||
self._container.setObjectName("DialogContainer")
|
||||
self._container.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self._container.setMouseTracking(True)
|
||||
# Прокручиваемая область для содержимого
|
||||
self._scroll_area = QScrollArea(self)
|
||||
self._scroll_area.setWidgetResizable(True)
|
||||
self._scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
main_layout.addWidget(self._scroll_area)
|
||||
|
||||
outer.addWidget(self._container)
|
||||
# Виджет-контейнер для содержимого
|
||||
self._content_widget = QWidget()
|
||||
self._scroll_area.setWidget(self._content_widget)
|
||||
|
||||
container_layout = QVBoxLayout(self._container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(0)
|
||||
|
||||
# ── Мини-заголовок ──────────────────────────────────────────
|
||||
self._title_bar = QWidget(self._container)
|
||||
self._title_bar.setObjectName("DialogTitleBar")
|
||||
self._title_bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self._title_bar.setFixedHeight(32)
|
||||
|
||||
tb_layout = QHBoxLayout(self._title_bar)
|
||||
tb_layout.setContentsMargins(10, 4, 6, 4)
|
||||
tb_layout.setSpacing(4)
|
||||
|
||||
self._title_label = QLabel()
|
||||
self._title_label.setObjectName("DialogTitle")
|
||||
tb_layout.addWidget(self._title_label, 1)
|
||||
|
||||
self._close_btn = self._build_window_button()
|
||||
self._close_btn.clicked.connect(self.close)
|
||||
tb_layout.addWidget(self._close_btn)
|
||||
|
||||
container_layout.addWidget(self._title_bar)
|
||||
|
||||
# Область содержимого (sub-layout, доступен через containerLayout())
|
||||
self._body = QWidget(self._container)
|
||||
self._body_layout = QVBoxLayout(self._body)
|
||||
self._body_layout.setContentsMargins(12, 8, 12, 12)
|
||||
container_layout.addWidget(self._body, 1)
|
||||
|
||||
self._drag_offset = None
|
||||
|
||||
# ── Публичные методы ────────────────────────────────────────────
|
||||
# Макет содержимого
|
||||
self._body_layout = QVBoxLayout(self._content_widget)
|
||||
self._body_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self._body_layout.setSpacing(8)
|
||||
|
||||
def setWindowTitle(self, title: str) -> None: # noqa: N802
|
||||
super().setWindowTitle(title)
|
||||
self._title_label.setText(title)
|
||||
|
||||
def containerLayout(self) -> QVBoxLayout:
|
||||
"""Вернуть layout области содержимого (под заголовком)."""
|
||||
"""Вернуть layout области содержимого (для совместимости с наследниками)."""
|
||||
return self._body_layout
|
||||
|
||||
# ── Построение кнопки ───────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_window_button() -> QToolButton:
|
||||
button = QToolButton()
|
||||
button.setProperty("windowControl", "close")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setFixedSize(24, 20)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER)
|
||||
if not close_icon.isNull():
|
||||
button.setIcon(close_icon)
|
||||
else:
|
||||
button.setText("×")
|
||||
button.setIconSize(QSize(10, 10))
|
||||
|
||||
shadow = QGraphicsDropShadowEffect(button)
|
||||
shadow.setBlurRadius(10)
|
||||
shadow.setOffset(0, 2)
|
||||
shadow.setColor(QColor(0, 0, 0, 80))
|
||||
button.setGraphicsEffect(shadow)
|
||||
|
||||
return button
|
||||
|
||||
# ── Перетаскивание ───────────────────────────────────────────────
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_offset = (
|
||||
event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
||||
)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
|
||||
self.move(event.globalPosition().toPoint() - self._drag_offset)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
|
||||
self._drag_offset = None
|
||||
super().mouseReleaseEvent(event)
|
||||
def sizeHint(self) -> QSize:
|
||||
"""Вычислить предпочтительный размер на основе содержимого, ограничивая высоту."""
|
||||
hint = self._scroll_area.widget().sizeHint()
|
||||
# Добавляем небольшой запас по ширине для вертикальной полосы прокрутки
|
||||
hint.setWidth(hint.width() + 18)
|
||||
# Ограничиваем высоту 720 пикселями на случай больших макетов (например, тест подсветки)
|
||||
if hint.height() > 720:
|
||||
hint.setHeight(720)
|
||||
return hint
|
||||
|
||||
@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QThread, Signal, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
@ -19,9 +19,11 @@ from PySide6.QtWidgets import (
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QSplitter,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...services.firmware_service import perform_authenticated_ota
|
||||
from ...ui.icons import get_icon_color, icon
|
||||
|
||||
# Переиспользуем воркер из диалога
|
||||
@ -50,6 +52,9 @@ class FirmwareSection(QWidget):
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(10)
|
||||
|
||||
# Вертикальный сплиттер для изменения высоты лога
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# ── Обновление прошивки ──
|
||||
fw_group = QGroupBox("Обновление прошивки")
|
||||
fw_layout = QVBoxLayout(fw_group)
|
||||
@ -84,13 +89,18 @@ class FirmwareSection(QWidget):
|
||||
self._update_btn.clicked.connect(self._start_update)
|
||||
fw_layout.addWidget(self._update_btn)
|
||||
|
||||
root.addWidget(fw_group)
|
||||
splitter.addWidget(fw_group)
|
||||
|
||||
# ── Лог ──
|
||||
self._log = QTextEdit()
|
||||
self._log.setReadOnly(True)
|
||||
self._log.setMaximumHeight(160)
|
||||
root.addWidget(self._log)
|
||||
splitter.addWidget(self._log)
|
||||
|
||||
# Задаем начальное распределение размеров (70% на верхнюю часть, 30% на лог)
|
||||
splitter.setStretchFactor(0, 70)
|
||||
splitter.setStretchFactor(1, 30)
|
||||
|
||||
root.addWidget(splitter, 1)
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────
|
||||
|
||||
@ -186,36 +196,14 @@ class FirmwareSection(QWidget):
|
||||
self._pick_btn.setEnabled(True)
|
||||
self.upload_finished.emit()
|
||||
return
|
||||
try:
|
||||
self._conn.send_command("04")
|
||||
self._log.append("→ 04")
|
||||
|
||||
# Контроллер шлёт прогресс 1-100, затем «Successful update»
|
||||
# и через ~4 сек перезагружается с «READY!».
|
||||
# Ждём до 60 сек (перепрошивка + перезагрузка).
|
||||
import time
|
||||
deadline = time.time() + 60.0
|
||||
success = False
|
||||
|
||||
while time.time() < deadline:
|
||||
remaining = max(deadline - time.time(), 0.1)
|
||||
resp = self._conn.read_response(timeout=min(remaining, 2.0))
|
||||
if resp is None:
|
||||
continue
|
||||
self._log.append(f"← {resp}")
|
||||
if "ready" in resp.lower():
|
||||
success = True
|
||||
break
|
||||
|
||||
self._conn.release()
|
||||
|
||||
if success:
|
||||
self._log.append("✓ Прошивка обновлена, плата перезагружена.")
|
||||
else:
|
||||
self._log.append("← таймаут (нет ответа)")
|
||||
except Exception as exc:
|
||||
self._conn.release()
|
||||
self._log.append(f"Ошибка OTA: {exc}")
|
||||
result = perform_authenticated_ota(
|
||||
self._conn,
|
||||
log_message=self._log.append,
|
||||
)
|
||||
if result.success:
|
||||
self._log.append(f"✓ {result.detail}.")
|
||||
else:
|
||||
self._log.append(f"✗ {result.detail}")
|
||||
self._update_btn.setEnabled(True)
|
||||
self._pick_btn.setEnabled(True)
|
||||
self.upload_finished.emit()
|
||||
|
||||
@ -6,18 +6,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...ui.icons import icon
|
||||
from ...ui.icons import get_icon_color, icon
|
||||
from ..no_wheel_combo_box import NoWheelComboBox
|
||||
|
||||
_COLOR_ORDERS: list[tuple[int, str]] = [
|
||||
@ -45,39 +50,86 @@ class LedTestSection(QWidget):
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(10)
|
||||
|
||||
# ── Тест цвета ──
|
||||
color_group = QGroupBox("Тест цвета (cmd 89)")
|
||||
color_root = QVBoxLayout(color_group)
|
||||
# ── Настройки WS2812 (cmd 89) ──
|
||||
color_group = QGroupBox("Настройки WS2812 (cmd 89)")
|
||||
color_form = QFormLayout(color_group)
|
||||
color_form.setSpacing(6)
|
||||
|
||||
desc = QLabel(
|
||||
"Выберите порядок цветов и нажмите «1. Применить». "
|
||||
"Затем нажмите «2. Тест цвета»: подсветка должна показать красный, зелёный и синий цвет."
|
||||
"Настройки вывода WS2812. Выберите физический канал и настройте порядок цветов, "
|
||||
"яркость или гамма-коррекцию."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setObjectName("HelpText")
|
||||
color_root.addWidget(desc)
|
||||
color_form.addRow(desc)
|
||||
|
||||
# Выбор физического канала
|
||||
self._cmd89_channel_combo = NoWheelComboBox()
|
||||
self._cmd89_channel_combo.addItem("Все ленты (0)", 0)
|
||||
self._cmd89_channel_combo.addItem("2-й канал (1)", 1)
|
||||
self._cmd89_channel_combo.addItem("3-й канал (2)", 2)
|
||||
self._cmd89_channel_combo.addItem("4-й канал (3)", 3)
|
||||
color_form.addRow("Физический канал:", self._cmd89_channel_combo)
|
||||
|
||||
# Порядок цветов
|
||||
self._color_order_combo = NoWheelComboBox()
|
||||
for code, name in _COLOR_ORDERS:
|
||||
self._color_order_combo.addItem(f"{code} — {name}", code)
|
||||
color_root.addWidget(self._color_order_combo)
|
||||
|
||||
self._set_color_btn = QPushButton("1. Применить")
|
||||
self._set_color_btn = QPushButton("Применить порядок")
|
||||
self._set_color_btn.setProperty("role", "primary")
|
||||
self._set_color_btn.setIcon(icon("fa5s.palette", color="#ecfdf5"))
|
||||
self._set_color_btn.clicked.connect(self._send_color_order)
|
||||
color_root.addWidget(self._set_color_btn)
|
||||
|
||||
self._color_test_btn = QPushButton("2. Тест цвета")
|
||||
order_layout = QHBoxLayout()
|
||||
order_layout.addWidget(self._color_order_combo, 1)
|
||||
order_layout.addWidget(self._set_color_btn)
|
||||
color_form.addRow("Порядок цветов:", order_layout)
|
||||
|
||||
# Яркость
|
||||
self._cmd89_brightness_spin = QSpinBox()
|
||||
self._cmd89_brightness_spin.setRange(0, 255)
|
||||
self._cmd89_brightness_spin.setValue(255)
|
||||
|
||||
apply_bright_btn = QPushButton("Применить яркость")
|
||||
apply_bright_btn.setIcon(icon("fa5s.sun", color=get_icon_color()))
|
||||
apply_bright_btn.clicked.connect(self._send_brightness)
|
||||
|
||||
bright_layout = QHBoxLayout()
|
||||
bright_layout.addWidget(self._cmd89_brightness_spin, 1)
|
||||
bright_layout.addWidget(apply_bright_btn)
|
||||
color_form.addRow("Яркость:", bright_layout)
|
||||
|
||||
# Гамма-коррекция
|
||||
self._cmd89_gamma_check = QCheckBox("Включить гамма-коррекцию")
|
||||
|
||||
apply_gamma_btn = QPushButton("Применить гамму")
|
||||
apply_gamma_btn.setIcon(icon("fa5s.adjust", color=get_icon_color()))
|
||||
apply_gamma_btn.clicked.connect(self._send_gamma)
|
||||
|
||||
gamma_layout = QHBoxLayout()
|
||||
gamma_layout.addWidget(self._cmd89_gamma_check, 1)
|
||||
gamma_layout.addWidget(apply_gamma_btn)
|
||||
color_form.addRow("Гамма-коррекция:", gamma_layout)
|
||||
|
||||
# Пользовательский цвет
|
||||
self._custom_color_btn = QPushButton("Выбрать и отправить цвет")
|
||||
self._custom_color_btn.setIcon(icon("fa5s.palette", color=get_icon_color()))
|
||||
self._custom_color_btn.clicked.connect(self._send_custom_color)
|
||||
color_form.addRow("Свой цвет (08):", self._custom_color_btn)
|
||||
|
||||
# Тест цвета
|
||||
self._color_test_btn = QPushButton("Тест цвета")
|
||||
self._color_test_btn.setProperty("role", "primary")
|
||||
self._color_test_btn.setIcon(icon("fa5s.eye", color="#ecfdf5"))
|
||||
self._color_test_btn.clicked.connect(self._run_color_test)
|
||||
color_root.addWidget(self._color_test_btn)
|
||||
color_form.addRow(self._color_test_btn)
|
||||
|
||||
color_hint = QLabel("Должны вывестись: красный → зелёный → синий")
|
||||
color_hint.setStyleSheet("color: #9ca3af; font-size: 12px;")
|
||||
color_hint.setStyleSheet("color: #9ca3af; font-size: 11px;")
|
||||
color_hint.setWordWrap(True)
|
||||
color_root.addWidget(color_hint)
|
||||
color_form.addRow(color_hint)
|
||||
|
||||
root.addWidget(color_group)
|
||||
|
||||
# ── Лог ──
|
||||
@ -94,8 +146,32 @@ class LedTestSection(QWidget):
|
||||
# ── Отправка ──────────────────────────────────────────────────────
|
||||
|
||||
def _send_color_order(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
code = self._color_order_combo.currentData()
|
||||
self._send(f"8900{code}")
|
||||
cmd = f"89{ch}0{code}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_brightness(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
val = self._cmd89_brightness_spin.value()
|
||||
cmd = f"89{ch}1{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_gamma(self) -> None:
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
val = 1 if self._cmd89_gamma_check.isChecked() else 0
|
||||
cmd = f"89{ch}2{val}"
|
||||
self._send(cmd)
|
||||
|
||||
def _send_custom_color(self) -> None:
|
||||
color = QColorDialog.getColor(Qt.GlobalColor.white, self, "Выберите цвет")
|
||||
if color.isValid():
|
||||
ch = self._cmd89_channel_combo.currentData()
|
||||
r = color.red()
|
||||
g = color.green()
|
||||
b = color.blue()
|
||||
cmd = f"8{ch}08{r:03d}{g:03d}{b:03d}"
|
||||
self._send(cmd)
|
||||
|
||||
def _run_color_test(self) -> None:
|
||||
self._color_test_cmds = ["8003", "8004", "8005", "8002", "8000"]
|
||||
|
||||
@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...services.com_service import ComConnection
|
||||
from ...ui.icons import get_icon_color, icon
|
||||
|
||||
|
||||
@ -27,10 +28,12 @@ class PaymentSection(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection: ComConnection,
|
||||
send_raw: Callable[[str], None],
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._conn = connection
|
||||
self._send_raw = send_raw
|
||||
self._auto_dispense_hopper: int = 0
|
||||
self._auto_dispense_timer: QTimer | None = None
|
||||
@ -56,16 +59,32 @@ class PaymentSection(QWidget):
|
||||
|
||||
# ── Монетоприёмник ──
|
||||
coin_group = QGroupBox("Монетоприёмник")
|
||||
coin_layout = QHBoxLayout(coin_group)
|
||||
coin_root_layout = QVBoxLayout(coin_group)
|
||||
|
||||
coin_row = QHBoxLayout()
|
||||
self._coin_label = QLabel("Монеты: —")
|
||||
self._coin_label.setMinimumWidth(120)
|
||||
coin_layout.addWidget(self._coin_label)
|
||||
coin_row.addWidget(self._coin_label)
|
||||
self._coin_confirm_btn = QPushButton("Подтвердить (10)")
|
||||
self._coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=get_icon_color()))
|
||||
self._coin_confirm_btn.setToolTip("Отправить подтверждение COIN (команда 10)")
|
||||
self._coin_confirm_btn.clicked.connect(lambda: self._send_raw("10"))
|
||||
coin_layout.addWidget(self._coin_confirm_btn)
|
||||
coin_layout.addStretch()
|
||||
coin_row.addWidget(self._coin_confirm_btn)
|
||||
coin_row.addStretch()
|
||||
coin_root_layout.addLayout(coin_row)
|
||||
|
||||
global_row = QHBoxLayout()
|
||||
self._global_summa_label = QLabel("Накопительный счетчик (cmd 93): —")
|
||||
global_row.addWidget(self._global_summa_label)
|
||||
|
||||
self._global_summa_refresh_btn = QPushButton("Запросить")
|
||||
self._global_summa_refresh_btn.setIcon(icon("fa5s.sync-alt", color=get_icon_color()))
|
||||
self._global_summa_refresh_btn.setToolTip("Запросить global_summa")
|
||||
self._global_summa_refresh_btn.clicked.connect(self._refresh_global_summa)
|
||||
global_row.addWidget(self._global_summa_refresh_btn)
|
||||
global_row.addStretch()
|
||||
coin_root_layout.addLayout(global_row)
|
||||
|
||||
root.addWidget(coin_group)
|
||||
|
||||
# ── Хопперы ──
|
||||
@ -133,6 +152,7 @@ class PaymentSection(QWidget):
|
||||
def refresh_icons(self) -> None:
|
||||
clr = get_icon_color()
|
||||
self._coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=clr))
|
||||
self._global_summa_refresh_btn.setIcon(icon("fa5s.sync-alt", color=clr))
|
||||
self._h1_on.setIcon(icon("fa5s.play", color=clr))
|
||||
self._h1_off.setIcon(icon("fa5s.stop", color=clr))
|
||||
self._h1_auto_btn.setIcon(icon("fa5s.cog", color=clr))
|
||||
@ -215,3 +235,20 @@ class PaymentSection(QWidget):
|
||||
f"Хоппер {hopper}",
|
||||
f"Ошибка выдачи: датчик хоппера {hopper} не сработал за 10 секунд.",
|
||||
)
|
||||
|
||||
def _refresh_global_summa(self) -> None:
|
||||
if not self._conn.is_connected:
|
||||
QMessageBox.warning(self, "Ошибка", "Порт не подключён.")
|
||||
return
|
||||
try:
|
||||
self._conn.send_command("93")
|
||||
resp = self._conn.read_response(timeout=0.4)
|
||||
self._conn.release()
|
||||
if resp:
|
||||
self._global_summa_label.setText(f"Накопительный счетчик (cmd 93): {resp}")
|
||||
self._event_log.setText(f"← {resp}")
|
||||
else:
|
||||
self._global_summa_label.setText("Накопительный счетчик (cmd 93): нет ответа")
|
||||
except Exception as exc:
|
||||
self._conn.release()
|
||||
QMessageBox.critical(self, "Ошибка", f"Не удалось прочитать счетчик: {exc}")
|
||||
|
||||
@ -1,17 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, QTimer, Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..services.update_service import (
|
||||
UpdateCancelled,
|
||||
UpdateInfo,
|
||||
check_for_update,
|
||||
current_executable,
|
||||
download_update,
|
||||
launch_update,
|
||||
)
|
||||
from ..ui.icons import ICON_COLOR_PRIMARY, get_icon_color, icon
|
||||
from .dialogs.commands_dialog import CommandsDialog
|
||||
from .dialogs.settings_dialog import SettingsDialog
|
||||
@ -21,6 +33,9 @@ class StartTab(QWidget):
|
||||
def __init__(self, on_new_connection) -> None:
|
||||
super().__init__()
|
||||
self._open_dialog_windows: dict[str, QWidget] = {}
|
||||
self._available_update: UpdateInfo | None = None
|
||||
self._update_check_thread: _UpdateCheckThread | None = None
|
||||
self._update_download_thread: _UpdateDownloadThread | None = None
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(26, 26, 26, 26)
|
||||
layout.addStretch(1)
|
||||
@ -81,17 +96,33 @@ class StartTab(QWidget):
|
||||
self._commands_btn.clicked.connect(self._open_commands)
|
||||
bottom_row.addWidget(self._commands_btn)
|
||||
|
||||
settings_column = QWidget()
|
||||
self._settings_column_layout = QVBoxLayout(settings_column)
|
||||
self._settings_column_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._settings_column_layout.setSpacing(8)
|
||||
|
||||
self._settings_btn = QPushButton("Настройки приложения")
|
||||
self._settings_btn.setMinimumHeight(38)
|
||||
self._settings_btn.setMinimumWidth(180)
|
||||
self._settings_btn.setIcon(icon("fa5s.cog", color=get_icon_color()))
|
||||
self._settings_btn.clicked.connect(self._open_settings)
|
||||
bottom_row.addWidget(self._settings_btn)
|
||||
self._settings_column_layout.addWidget(self._settings_btn)
|
||||
|
||||
self._update_btn = QPushButton("Скачать обновление")
|
||||
self._update_btn.setProperty("role", "primary")
|
||||
self._update_btn.setMinimumHeight(38)
|
||||
self._update_btn.setMinimumWidth(180)
|
||||
self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY))
|
||||
self._update_btn.clicked.connect(self._start_update_download)
|
||||
self._update_btn.hide()
|
||||
self._settings_column_layout.addWidget(self._update_btn)
|
||||
bottom_row.addWidget(settings_column, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
bottom_row.addStretch(1)
|
||||
layout.addLayout(bottom_row)
|
||||
|
||||
layout.addStretch(2)
|
||||
QTimer.singleShot(500, self._start_update_check)
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────
|
||||
|
||||
@ -100,6 +131,14 @@ class StartTab(QWidget):
|
||||
clr = get_icon_color()
|
||||
self._commands_btn.setIcon(icon("fa5s.book", color=clr))
|
||||
self._settings_btn.setIcon(icon("fa5s.cog", color=clr))
|
||||
self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Остановить фоновые задачи перед закрытием приложения."""
|
||||
for thread in (self._update_check_thread, self._update_download_thread):
|
||||
if thread is not None and thread.isRunning():
|
||||
thread.requestInterruption()
|
||||
thread.wait(35_000)
|
||||
|
||||
# ── Handlers ──────────────────────────────────────────────────
|
||||
|
||||
@ -132,3 +171,121 @@ class StartTab(QWidget):
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
self._open_single_dialog("settings", lambda: SettingsDialog(self))
|
||||
|
||||
# ── Обновление приложения ─────────────────────────────────────
|
||||
|
||||
def _start_update_check(self) -> None:
|
||||
if self._update_check_thread is not None:
|
||||
return
|
||||
thread = _UpdateCheckThread(parent=self)
|
||||
thread.result_ready.connect(self._on_update_check_done)
|
||||
thread.finished.connect(self._cleanup_update_check)
|
||||
self._update_check_thread = thread
|
||||
thread.start()
|
||||
|
||||
def _on_update_check_done(self, info: UpdateInfo | None) -> None:
|
||||
self._available_update = info
|
||||
if info is None:
|
||||
self._update_btn.hide()
|
||||
return
|
||||
self._update_btn.setText("Скачать обновление")
|
||||
self._update_btn.setToolTip(f"Доступна версия {info.tag}")
|
||||
self._update_btn.setEnabled(True)
|
||||
self._update_btn.show()
|
||||
|
||||
def _cleanup_update_check(self) -> None:
|
||||
if self._update_check_thread is not None:
|
||||
self._update_check_thread.deleteLater()
|
||||
self._update_check_thread = None
|
||||
|
||||
def _start_update_download(self) -> None:
|
||||
info = self._available_update
|
||||
if info is None or self._update_download_thread is not None:
|
||||
return
|
||||
try:
|
||||
target_directory = current_executable().parent
|
||||
except Exception as exc:
|
||||
QMessageBox.information(self, "Обновление приложения", str(exc))
|
||||
return
|
||||
|
||||
self._update_btn.setEnabled(False)
|
||||
self._update_btn.setText("Скачивание 0%")
|
||||
thread = _UpdateDownloadThread(info, target_directory, parent=self)
|
||||
thread.progress_changed.connect(self._on_update_progress)
|
||||
thread.result_ready.connect(self._on_update_downloaded)
|
||||
thread.failed.connect(self._on_update_failed)
|
||||
thread.finished.connect(self._cleanup_update_download)
|
||||
self._update_download_thread = thread
|
||||
thread.start()
|
||||
|
||||
def _on_update_progress(self, percent: int) -> None:
|
||||
self._update_btn.setText(f"Скачивание {percent}%")
|
||||
|
||||
def _on_update_downloaded(self, path: Path) -> None:
|
||||
self._update_btn.setText("Перезапуск…")
|
||||
try:
|
||||
launch_update(path)
|
||||
except Exception as exc:
|
||||
path.unlink(missing_ok=True)
|
||||
self._on_update_failed(str(exc))
|
||||
return
|
||||
self.window().close()
|
||||
|
||||
def _on_update_failed(self, detail: str) -> None:
|
||||
self._update_btn.setText("Скачать обновление")
|
||||
self._update_btn.setEnabled(True)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Обновление приложения",
|
||||
f"Не удалось установить обновление:\n{detail}",
|
||||
)
|
||||
|
||||
def _cleanup_update_download(self) -> None:
|
||||
if self._update_download_thread is not None:
|
||||
self._update_download_thread.deleteLater()
|
||||
self._update_download_thread = None
|
||||
|
||||
|
||||
class _UpdateCheckThread(QThread):
|
||||
result_ready = Signal(object)
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
info = check_for_update(__version__)
|
||||
except Exception:
|
||||
info = None
|
||||
if not self.isInterruptionRequested():
|
||||
self.result_ready.emit(info)
|
||||
|
||||
|
||||
class _UpdateDownloadThread(QThread):
|
||||
progress_changed = Signal(int)
|
||||
result_ready = Signal(object)
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
info: UpdateInfo,
|
||||
target_directory: Path,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._info = info
|
||||
self._target_directory = target_directory
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
path = download_update(
|
||||
self._info,
|
||||
self._target_directory,
|
||||
progress=self.progress_changed.emit,
|
||||
cancelled=self.isInterruptionRequested,
|
||||
)
|
||||
except UpdateCancelled:
|
||||
return
|
||||
except Exception as exc:
|
||||
if not self.isInterruptionRequested():
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
if not self.isInterruptionRequested():
|
||||
self.result_ready.emit(path)
|
||||
|
||||
@ -1,24 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, QSize
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsDropShadowEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QTabBar,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..ui.icons import ICON_COLOR_PRIMARY, ICON_COLOR_DANGER, icon
|
||||
|
||||
|
||||
class TitleBar(QWidget):
|
||||
"""Верхняя панель вкладок, отображаемая под системным заголовком главного окна."""
|
||||
|
||||
def __init__(self, window: QWidget) -> None:
|
||||
super().__init__(window)
|
||||
self._window = window
|
||||
self._drag_offset = None
|
||||
|
||||
self.setObjectName("TitleBar")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
@ -26,19 +20,10 @@ class TitleBar(QWidget):
|
||||
self.setFixedHeight(38)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 4, 6, 4)
|
||||
layout.setContentsMargins(10, 4, 10, 4)
|
||||
layout.setSpacing(8)
|
||||
self._layout = layout
|
||||
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setObjectName("TitleBarIcon")
|
||||
self.icon_label.setFixedSize(16, 16)
|
||||
layout.addWidget(self.icon_label)
|
||||
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("TitleBarTitle")
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
self.tabs_container = QWidget(self)
|
||||
self.tabs_container.setObjectName("TitleTabsContainer")
|
||||
self.tabs_layout = QHBoxLayout(self.tabs_container)
|
||||
@ -46,78 +31,8 @@ class TitleBar(QWidget):
|
||||
self.tabs_layout.setSpacing(0)
|
||||
layout.addWidget(self.tabs_container, 1)
|
||||
|
||||
self.min_btn = self._build_window_button("—", "minimize")
|
||||
self.min_btn.clicked.connect(self._window.showMinimized)
|
||||
layout.addWidget(self.min_btn)
|
||||
|
||||
self.max_btn = self._build_window_button("□", "maximize")
|
||||
self.max_btn.clicked.connect(self._toggle_max_restore)
|
||||
layout.addWidget(self.max_btn)
|
||||
|
||||
self.close_btn = self._build_window_button("×", "close")
|
||||
self.close_btn.clicked.connect(self._window.close)
|
||||
layout.addWidget(self.close_btn)
|
||||
|
||||
self._window.installEventFilter(self)
|
||||
self._sync_window_meta()
|
||||
|
||||
def _build_window_button(self, text: str, role: str) -> QToolButton:
|
||||
button = QToolButton(self)
|
||||
button.setText(text)
|
||||
button.setProperty("windowControl", role)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setFixedSize(30, 24)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
if role == "minimize":
|
||||
minimize_icon = icon("fa5s.minus", color=ICON_COLOR_PRIMARY)
|
||||
if not minimize_icon.isNull():
|
||||
button.setIcon(minimize_icon)
|
||||
button.setText("")
|
||||
elif role == "maximize":
|
||||
maximize_icon = icon("fa5s.square", color=ICON_COLOR_PRIMARY)
|
||||
if not maximize_icon.isNull():
|
||||
button.setIcon(maximize_icon)
|
||||
button.setText("")
|
||||
elif role == "close":
|
||||
close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER)
|
||||
if not close_icon.isNull():
|
||||
button.setIcon(close_icon)
|
||||
button.setText("")
|
||||
|
||||
button.setIconSize(QSize(12, 12))
|
||||
|
||||
shadow = QGraphicsDropShadowEffect(button)
|
||||
shadow.setBlurRadius(10)
|
||||
shadow.setOffset(0, 2)
|
||||
shadow.setColor(QColor(0, 0, 0, 80))
|
||||
button.setGraphicsEffect(shadow)
|
||||
|
||||
return button
|
||||
|
||||
def _sync_window_meta(self) -> None:
|
||||
self.title_label.setText(self._window.windowTitle())
|
||||
window_icon = self._window.windowIcon()
|
||||
if not window_icon.isNull():
|
||||
self.icon_label.setPixmap(window_icon.pixmap(16, 16))
|
||||
max_icon = icon(
|
||||
"fa5s.clone" if self._window.isMaximized() else "fa5s.square",
|
||||
color=ICON_COLOR_PRIMARY,
|
||||
)
|
||||
if not max_icon.isNull():
|
||||
self.max_btn.setIcon(max_icon)
|
||||
self.max_btn.setText("")
|
||||
else:
|
||||
self.max_btn.setText("❐" if self._window.isMaximized() else "□")
|
||||
|
||||
def _toggle_max_restore(self) -> None:
|
||||
if self._window.isMaximized():
|
||||
self._window.showNormal()
|
||||
else:
|
||||
self._window.showMaximized()
|
||||
self._sync_window_meta()
|
||||
|
||||
def set_tabs_widget(self, widget: QWidget) -> None:
|
||||
"""Встроить виджет вкладок в панель."""
|
||||
while self.tabs_layout.count():
|
||||
item = self.tabs_layout.takeAt(0)
|
||||
if item is not None:
|
||||
@ -125,62 +40,3 @@ class TitleBar(QWidget):
|
||||
if old_widget is not None:
|
||||
old_widget.setParent(None)
|
||||
self.tabs_layout.addWidget(widget)
|
||||
# Перехватываем события мыши на QTabBar для перетаскивания
|
||||
# за пустую область вкладок
|
||||
widget.installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched, event): # type: ignore[override]
|
||||
if watched is self._window and event.type() in (
|
||||
QEvent.Type.WindowTitleChange,
|
||||
QEvent.Type.WindowIconChange,
|
||||
QEvent.Type.WindowStateChange,
|
||||
):
|
||||
self._sync_window_meta()
|
||||
|
||||
# Перетаскивание окна за пустую область QTabBar
|
||||
if isinstance(watched, QTabBar):
|
||||
etype = event.type()
|
||||
if etype == QEvent.Type.MouseButtonPress:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if watched.tabAt(event.position().toPoint()) == -1:
|
||||
self._drag_offset = (
|
||||
event.globalPosition().toPoint()
|
||||
- self._window.frameGeometry().topLeft()
|
||||
)
|
||||
return True
|
||||
elif etype == QEvent.Type.MouseMove:
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
|
||||
if not self._window.isMaximized():
|
||||
self._window.move(event.globalPosition().toPoint() - self._drag_offset)
|
||||
return True
|
||||
elif etype == QEvent.Type.MouseButtonRelease:
|
||||
if self._drag_offset is not None:
|
||||
self._drag_offset = None
|
||||
return True
|
||||
elif etype == QEvent.Type.MouseButtonDblClick:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if watched.tabAt(event.position().toPoint()) == -1:
|
||||
self._toggle_max_restore()
|
||||
return True
|
||||
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_offset = event.globalPosition().toPoint() - self._window.frameGeometry().topLeft()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
|
||||
if not self._window.isMaximized():
|
||||
self._window.move(event.globalPosition().toPoint() - self._drag_offset)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
|
||||
self._drag_offset = None
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._toggle_max_restore()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
@ -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 "Очистка завершена."
|
||||
}
|
||||
@ -51,16 +54,36 @@ if ($InstallBuildDeps) {
|
||||
# ── Сборка ─────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "=== Сборка EXE ==="
|
||||
& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "PyInstaller завершился с ошибкой (код $LASTEXITCODE)."
|
||||
$pythonHome = (& $venvPython -c "import sys; print(sys.base_prefix)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $pythonHome -PathType Container)) {
|
||||
throw "Не удалось определить каталог Python для сборки."
|
||||
}
|
||||
$buildPath = @(
|
||||
(Split-Path -Parent $venvPython),
|
||||
$pythonHome,
|
||||
(Join-Path $pythonHome "DLLs"),
|
||||
(Join-Path $env:SystemRoot "System32"),
|
||||
$env:SystemRoot
|
||||
) -join ";"
|
||||
$originalPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = $buildPath
|
||||
& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
|
||||
$buildExitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$env:PATH = $originalPath
|
||||
}
|
||||
|
||||
$exe = Join-Path $projectRoot "dist\plate_config_app.exe"
|
||||
if (-not (Test-Path $exe)) {
|
||||
if ($buildExitCode -ne 0) {
|
||||
throw "PyInstaller завершился с ошибкой (код $buildExitCode)."
|
||||
}
|
||||
|
||||
$builtExe = Join-Path $projectRoot "dist\plate_config_app.exe"
|
||||
if (-not (Test-Path $builtExe)) {
|
||||
throw "EXE не найден после сборки."
|
||||
}
|
||||
$exe = Join-Path $projectRoot "dist\plate_config_app-windows-x86_64.exe"
|
||||
Move-Item -LiteralPath $builtExe -Destination $exe -Force
|
||||
|
||||
# ── Копирование ресурсных папок рядом с EXE ───────────────────────
|
||||
Write-Host ""
|
||||
@ -77,6 +100,18 @@ foreach ($folder in $resourceFolders) {
|
||||
}
|
||||
}
|
||||
|
||||
$archive = Join-Path $projectRoot "dist\plate_config_app-windows-x86_64.zip"
|
||||
$archiveEntries = @($exe)
|
||||
foreach ($folder in $resourceFolders) {
|
||||
$resourcePath = Join-Path $projectRoot "dist\$folder"
|
||||
if (Test-Path $resourcePath) {
|
||||
$archiveEntries += $resourcePath
|
||||
}
|
||||
}
|
||||
Compress-Archive -LiteralPath $archiveEntries -DestinationPath $archive -Force
|
||||
|
||||
$size = [math]::Round((Get-Item $exe).Length / 1MB, 1)
|
||||
$archiveSize = [math]::Round((Get-Item $archive).Length / 1MB, 1)
|
||||
Write-Host ""
|
||||
Write-Host "Сборка завершена: $exe ($size MB)"
|
||||
Write-Host "ZIP-архив: $archive ($archiveSize MB)"
|
||||
|
||||
59
build_linux.sh
Normal file
59
build_linux.sh
Normal file
@ -0,0 +1,59 @@
|
||||
#!/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"
|
||||
artifact_name="plate_config_app-linux-x86_64"
|
||||
archive_name="plate_config_app-linux-x86_64.zip"
|
||||
|
||||
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
|
||||
|
||||
install -m 0755 "$dist_dir/plate_config_app" "$dist_dir/$artifact_name"
|
||||
rm -f "$dist_dir/plate_config_app"
|
||||
for resource_dir in light_modes input_mappings commands; do
|
||||
cp -a "$resource_dir" "$dist_dir/$resource_dir"
|
||||
done
|
||||
|
||||
cat > "$dist_dir/README.txt" <<'EOF'
|
||||
Plate Config App для Debian 13 x86_64
|
||||
|
||||
Запуск:
|
||||
./plate_config_app-linux-x86_64
|
||||
|
||||
Папки light_modes, input_mappings и commands можно изменять без пересборки.
|
||||
Для доступа к USB-COM добавьте пользователя в группу dialout и войдите в систему заново:
|
||||
sudo usermod -aG dialout "$USER"
|
||||
EOF
|
||||
|
||||
(
|
||||
cd "$dist_dir"
|
||||
"$venv_dir/bin/python" -m zipfile -c \
|
||||
"$archive_name" \
|
||||
"$artifact_name" \
|
||||
light_modes \
|
||||
input_mappings \
|
||||
commands \
|
||||
README.txt
|
||||
)
|
||||
|
||||
printf 'Готово: %s\n' "$dist_dir/$artifact_name"
|
||||
sha256sum "$dist_dir/$artifact_name"
|
||||
printf 'ZIP-архив: %s\n' "$dist_dir/$archive_name"
|
||||
sha256sum "$dist_dir/$archive_name"
|
||||
@ -1,8 +1,8 @@
|
||||
command;name;description
|
||||
00;Перезагрузка;Перезагрузить контроллер (RESET). Помещается в очередь без ожидания.
|
||||
01;Отладка;Включает/выключает подробные логи. В релизе будет отключена.
|
||||
01;Отладка;Включает/выключает подробные логи (SILENT / DEVELOPER). В релизе будет отключена.
|
||||
02;Приём файла;Подготовиться к приёму файла обновления. Параметр — размер в байтах. Пример: 02356987. Выполняется ~15 сек, затем весь приём данных записывается в update.bin.
|
||||
03;SPIFFS;Показать содержимое SPIFFS (встроенной файловой системы).
|
||||
03;SPIFFS;Показать содержимое встроенной файловой системы SPIFFS.
|
||||
04;OTA обновление;Запустить OTA-обновление с ранее записанным файлом /update.bin.
|
||||
05;Версия прошивки;Показать версию прошивки контроллера.
|
||||
06;MAC-адрес;Показать MAC-адрес (без двоеточий). Уникальный, можно считать серийным номером.
|
||||
@ -10,21 +10,26 @@ command;name;description
|
||||
08;Авторизация (ответ);Приём ответа от компьютера на встречный вызов CHAL. 32 HEX-символа зашифрованных AES256.
|
||||
09;Сброс EEPROM;Сброс EEPROM. Параметр — MAC-адрес без двоеточий, иначе не сбросится.
|
||||
10;Подтверждение COIN;Подтвердить принятие сигнала COIN. Без подтверждения сигнал повторяется каждые 200 мс.
|
||||
20X;Выходы (маска);Установить состояние 8 выходов двоичной маской. Пример: 203 → включить выходы 1 и 2.
|
||||
21XY;Выход (один);Установить выход X в состояние Y [0/1/2(инверсия)]. Пример: 2131 → включить выход 3.
|
||||
20X;Выходы (маска);Установить состояние 8 выходов двоичной маской. Пример: 203 → вывести 1 и 2 выходы по единичке, остальные - нолики.
|
||||
21XY;Выход (один);Установить выход X (0-7) в состояние Y [0/1/2(инверсия)]. Пример: 2131 → включить выход 3.
|
||||
25CCB;Витрина призов;25 — запрос состояния; 2501B — выдать приз из ячейки B; 2511B — открыть ячейку B без списания; 25991/25990 — вкл/выкл авторежим витрины.
|
||||
30X;Хоппер 1;301 — включить мотор, 300 — выключить, 309 — выдать приз (авторежим с контролем датчика).
|
||||
40X;Хоппер 2;Аналогично хопперу 1.
|
||||
8XYY;Программа LED;Выбор программы YY адресных диодов по каналу X (1..4). 80YY — на все каналы. 0 — выкл, 3 — красный, 4 — зелёный, 5 — синий.
|
||||
85XXYY;Спецэффект LED;Специфичная программа эффектов. XX — код эффекта, YY — время в секундах. Пример: 854505 — ЗАРЯДКА на 5 сек.
|
||||
50CCP;Дополнительные GPIO;GPIO25/GPIO26: 50 — запрос состояния; 5001P — настроить режим; 5005P — запрос уровня; 5010P — установить выходы.
|
||||
8XYY;Программа LED;Выбор программы YY по каналу X (1..4). 80YY — на все каналы. 08RRRGGGBBB — цвет. 8012.. / 8013.. — direct-группы монетника/выдачи.
|
||||
81XX[NN];Бродяга морей;Управление direct-объектами Бродяги морей. 8130 — очистить; 8131NN / 8132NN — уровень; 81311..81313 / 81321..81323 — эффекты.
|
||||
85XX[SSS];Эффекты VM-матрицы;Эффекты s_b: 00/01 — авто вкл/выкл; 25 — тесты; 30..34 — direct-шкалы; 10..22 — базовые; 40..49 — спец; 80..82 — цвета. SSS — время.
|
||||
85;Стоп рендеринг;Без параметра — отключить процесс рендеринга эффектов (освободить RAM).
|
||||
89[X];Цветовой порядок;Переключить чередование цветов: 0/пусто — RGB, 1 — RBG, 2 — BRG, 3 — BGR, 4 — GRB, 5 — GBR.
|
||||
8550XX[SSS];Данро (принудительно);Данро (принудительная матрица dan_ro): 855000 — очистить; 855001 — авто вкл; 855010..855016 — эффекты. SSS — время.
|
||||
8560XX[SSS];RZ (принудительно);Резервная панель RZ (принудительная матрица rz): 856000 — очистить; 856001 — авто вкл; 856010..856016 — эффекты. SSS — время.
|
||||
89CKP;Настройки WS2812;C — канал (0..3); K — настройка: 0 порядок (0..5 для RGB..GBR), 1 яркость (0..255), 2 гамма (0/1); P — значение.
|
||||
90;Очистка очереди;Очистить очередь команд.
|
||||
91;Входы;Запрос состояния 26 цифровых входов. Ответ: INPUT<число>. Пример: INPUT67108863 — все не нажаты.
|
||||
91;Входы;Запрос состояния 24 цифровых (+2 хоппера) входов в двоичном коде (преобразованном в десятичный). Пример: INPUT67108863.
|
||||
91X;Антидребезг;Задать время антидребезга (10..100 мс). По умолчанию 50 мс. Пример: 9110.
|
||||
92;ADC (однократно);Запрос значений 4 аналоговых входов. Отключает автовывод. Ответ: ADC0,0,0,0.
|
||||
92X;ADC (авто);Периодический автовывод ADC каждые X мс (50..5000). Пример: 92100.
|
||||
93;Счётчик монет;Запрос global_summa — нестираемый счётчик принятых монет.
|
||||
96;Сохранить роль;Сохранить пользовательское значение в памяти платы (до 20 символов). Пример: 96right.
|
||||
97;Показать роль;Вывод сохранённого пользовательского значения. Формат: ROLE : <значение>.
|
||||
96;Активная матрица;Без параметра — запрос текущей активной VM-матрицы. С параметром — выбор и сохранение матрицы в NVS (s_b, dan_ro, rz, ...).
|
||||
97;Показать активную матрицу;Вывод сохранённого в NVS имени активной матрицы. Формат: ROLE : <имя>.
|
||||
98;Состояние системы;Запрос загруженности ядер, оперативки и свободного места в памяти.
|
||||
99;Статус;Запрос текущего статуса платы.
|
||||
|
||||
|
Can't render this file because it has a wrong number of fields in line 15.
|
@ -8,19 +8,19 @@ has_duration = true
|
||||
# ── Базовые эффекты ──
|
||||
|
||||
[[effects]]
|
||||
code = 0
|
||||
code = 10
|
||||
name = "Горизонтальная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 1
|
||||
code = 11
|
||||
name = "Вертикальная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 2
|
||||
code = 12
|
||||
name = "Диагональная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 3
|
||||
code = 13
|
||||
name = "Плазма"
|
||||
|
||||
[[effects]]
|
||||
@ -28,35 +28,35 @@ code = 14
|
||||
name = "Ночь"
|
||||
|
||||
[[effects]]
|
||||
code = 5
|
||||
code = 15
|
||||
name = "Радуга"
|
||||
|
||||
[[effects]]
|
||||
code = 6
|
||||
code = 16
|
||||
name = "Огонь-Лёд"
|
||||
|
||||
[[effects]]
|
||||
code = 7
|
||||
code = 17
|
||||
name = "Вода"
|
||||
|
||||
[[effects]]
|
||||
code = 8
|
||||
code = 18
|
||||
name = "Звёзды"
|
||||
|
||||
[[effects]]
|
||||
code = 9
|
||||
code = 19
|
||||
name = "Сканер"
|
||||
|
||||
[[effects]]
|
||||
code = 10
|
||||
code = 20
|
||||
name = "Поток"
|
||||
|
||||
[[effects]]
|
||||
code = 11
|
||||
code = 21
|
||||
name = "Буря"
|
||||
|
||||
[[effects]]
|
||||
code = 12
|
||||
code = 22
|
||||
name = "Лёд"
|
||||
|
||||
# ── Специальные эффекты ──
|
||||
|
||||
58
light_modes/Резервная панель RZ.lm
Normal file
58
light_modes/Резервная панель RZ.lm
Normal file
@ -0,0 +1,58 @@
|
||||
# Резервная панель RZ — специальные эффекты подсветки
|
||||
# Формат команды: 87XXYY (XX — код эффекта, YY — время в секундах)
|
||||
# Без параметра (87) — отключить рендеринг эффектов
|
||||
|
||||
base_command = "87"
|
||||
has_duration = true
|
||||
|
||||
[[effects]]
|
||||
code = 40
|
||||
name = "Горизонтальная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 41
|
||||
name = "Вертикальная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 42
|
||||
name = "Диагональная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 43
|
||||
name = "Радуга"
|
||||
|
||||
[[effects]]
|
||||
code = 44
|
||||
name = "Волна вверх"
|
||||
|
||||
[[effects]]
|
||||
code = 45
|
||||
name = "Вертикальная волна вверх"
|
||||
|
||||
[[effects]]
|
||||
code = 46
|
||||
name = "Конечная волна"
|
||||
|
||||
[[effects]]
|
||||
code = 47
|
||||
name = "Синяя волна"
|
||||
|
||||
[[effects]]
|
||||
code = 48
|
||||
name = "Диагональная волна 2"
|
||||
|
||||
[[effects]]
|
||||
code = 49
|
||||
name = "Падающие ряды"
|
||||
|
||||
[[effects]]
|
||||
code = 50
|
||||
name = "Оранжевая волна"
|
||||
|
||||
[[effects]]
|
||||
code = 51
|
||||
name = "Морская волна"
|
||||
|
||||
[[effects]]
|
||||
code = 52
|
||||
name = "Диагональная волна 3"
|
||||
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
|
||||
|
||||
|
||||
262
tests/test_auth_service.py
Normal file
262
tests/test_auth_service.py
Normal file
@ -0,0 +1,262 @@
|
||||
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,
|
||||
firmware_requires_auth,
|
||||
perform_handshake,
|
||||
perform_required_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
|
||||
self.firmware_version: str | None = None
|
||||
self.auth_required: bool | None = None
|
||||
|
||||
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
|
||||
|
||||
def set_firmware_version(self, version: str, *, auth_required: bool) -> None:
|
||||
self.firmware_version = version
|
||||
self.auth_required = auth_required
|
||||
|
||||
|
||||
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)
|
||||
self.assertEqual("2.3.6 release", conn.firmware_version)
|
||||
self.assertTrue(conn.auth_required)
|
||||
|
||||
def test_no_auth_banner_skips_handshake(self) -> None:
|
||||
conn = FakeConnection([
|
||||
"boot log",
|
||||
"periphery controller ver_2.3.6_No_Auth_prizes",
|
||||
])
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
result = perform_startup_handshake(
|
||||
conn,
|
||||
startup_delay=2.0,
|
||||
sleep_fn=sleep_calls.append,
|
||||
)
|
||||
|
||||
self.assertEqual(AuthStatus.OK, result.status)
|
||||
self.assertTrue(result.startup_ready)
|
||||
self.assertTrue(result.auth_skipped)
|
||||
self.assertEqual("2.3.6_No_Auth_prizes", result.firmware_version)
|
||||
self.assertFalse(conn.auth_required)
|
||||
self.assertEqual([2.0], sleep_calls)
|
||||
self.assertFalse(any(event.startswith("send:07") for event in conn.events))
|
||||
self.assertEqual(1, conn.release_count)
|
||||
|
||||
def test_auth_mode_marker_is_case_insensitive(self) -> None:
|
||||
self.assertFalse(firmware_requires_auth("2.3.6_no_auth_prizes"))
|
||||
self.assertFalse(firmware_requires_auth("2.3.6_NO_AUTH"))
|
||||
self.assertTrue(firmware_requires_auth("2.3.6_release"))
|
||||
|
||||
def test_manual_handshake_is_skipped_for_known_no_auth_firmware(self) -> None:
|
||||
conn = FakeConnection([])
|
||||
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
|
||||
|
||||
result = perform_required_handshake(conn, timeout=0.1)
|
||||
|
||||
self.assertEqual(AuthStatus.OK, result.status)
|
||||
self.assertTrue(result.auth_skipped)
|
||||
self.assertFalse(any(event.startswith("send:") for event in conn.events))
|
||||
|
||||
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))
|
||||
|
||||
def test_no_auth_status_accepts_any_non_empty_response(self) -> None:
|
||||
conn = FakeConnection(["OK"])
|
||||
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
|
||||
|
||||
self.assertEqual(AuthStatus.OK, check_status(conn, timeout=0.1))
|
||||
|
||||
def test_no_auth_status_timeout_is_connection_failure(self) -> None:
|
||||
conn = FakeConnection([None])
|
||||
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
|
||||
|
||||
self.assertEqual(AuthStatus.DISCONNECTED, check_status(conn, timeout=0.1))
|
||||
|
||||
def test_new_secure_banner_reenables_handshake(self) -> None:
|
||||
conn = FakeConnection([
|
||||
"periphery controller ver_2.0.0_release",
|
||||
self._CHAL,
|
||||
self._RAES,
|
||||
"READY!",
|
||||
])
|
||||
conn.set_firmware_version("1.0.0_no_auth", auth_required=False)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.os.urandom",
|
||||
return_value=self._HOST_CHALLENGE,
|
||||
):
|
||||
result = perform_startup_handshake(
|
||||
conn,
|
||||
startup_delay=0.0,
|
||||
sleep_fn=lambda _seconds: None,
|
||||
)
|
||||
|
||||
self.assertEqual(AuthStatus.OK, result.status)
|
||||
self.assertFalse(result.auth_skipped)
|
||||
self.assertTrue(conn.auth_required)
|
||||
self.assertTrue(any(event.startswith("send:07") for event in conn.events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
95
tests/test_com_service.py
Normal file
95
tests/test_com_service.py
Normal file
@ -0,0 +1,95 @@
|
||||
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")
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_open = False
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
def test_close_clears_detected_firmware_mode(self) -> None:
|
||||
conn = ComConnection()
|
||||
conn._serial = FakeSerial()
|
||||
conn.set_firmware_version("2.0.0_no_auth", auth_required=False)
|
||||
|
||||
conn.close()
|
||||
|
||||
self.assertIsNone(conn.firmware_version)
|
||||
self.assertIsNone(conn.auth_required)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
115
tests/test_firmware_service.py
Normal file
115
tests/test_firmware_service.py
Normal file
@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.auth_service import AuthStatus
|
||||
from app.services.firmware_service import perform_authenticated_ota
|
||||
|
||||
|
||||
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
|
||||
self.firmware_version: str | None = None
|
||||
self.auth_required: bool | None = None
|
||||
|
||||
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
|
||||
|
||||
def set_firmware_version(self, version: str, *, auth_required: bool) -> None:
|
||||
self.firmware_version = version
|
||||
self.auth_required = auth_required
|
||||
|
||||
|
||||
class AuthenticatedOtaTests(unittest.TestCase):
|
||||
_HOST_CHALLENGE = bytes.fromhex("00112233445566778899AABBCCDDEEFF")
|
||||
_CHAL = "CHAL00112233445566778899AABBCCDDEEFF"
|
||||
_RAES = "RAES40E9A18C22C2CFCD764666C90B358F9D"
|
||||
|
||||
def _run(self, conn: FakeConnection):
|
||||
with patch(
|
||||
"app.services.auth_service.os.urandom",
|
||||
return_value=self._HOST_CHALLENGE,
|
||||
):
|
||||
return perform_authenticated_ota(
|
||||
conn,
|
||||
ota_timeout=0.1,
|
||||
banner_timeout=0.1,
|
||||
startup_delay=0.0,
|
||||
handshake_timeout=0.1,
|
||||
ready_timeout=0.1,
|
||||
sleep_fn=lambda _seconds: None,
|
||||
)
|
||||
|
||||
def test_sends_04_before_authentication_after_successful_update(self) -> None:
|
||||
conn = FakeConnection([
|
||||
"Writing 100%",
|
||||
"Successful update",
|
||||
"periphery controller ver_2.4.0 release",
|
||||
self._CHAL,
|
||||
self._RAES,
|
||||
"READY!",
|
||||
])
|
||||
|
||||
result = self._run(conn)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(AuthStatus.OK, result.post_auth.status)
|
||||
send_events = [event for event in conn.events if event.startswith("send:")]
|
||||
self.assertEqual("send:04", send_events[0])
|
||||
self.assertTrue(send_events[1].startswith("send:07"))
|
||||
|
||||
def test_sends_04_without_pre_authentication(self) -> None:
|
||||
conn = FakeConnection([None])
|
||||
|
||||
result = self._run(conn)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual("send:04", conn.events[0])
|
||||
self.assertFalse(any(event.startswith("send:07") for event in conn.events))
|
||||
|
||||
def test_no_auth_firmware_skips_post_ota_handshake(self) -> None:
|
||||
conn = FakeConnection([
|
||||
"Successful update",
|
||||
"periphery controller ver_2.0.0_no_auth",
|
||||
])
|
||||
|
||||
result = self._run(conn)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(AuthStatus.OK, result.post_auth.status)
|
||||
self.assertTrue(result.post_auth.auth_skipped)
|
||||
self.assertFalse(conn.auth_required)
|
||||
self.assertFalse(any(event.startswith("send:07") for event in conn.events))
|
||||
self.assertIn("без авторизации", result.detail)
|
||||
|
||||
def test_reports_post_authentication_failure_after_update(self) -> None:
|
||||
conn = FakeConnection([
|
||||
"Successful update",
|
||||
None,
|
||||
])
|
||||
|
||||
result = self._run(conn)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(AuthStatus.TIMEOUT, result.post_auth.status)
|
||||
self.assertIn("после OTA", result.detail)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
160
tests/test_start_tab.py
Normal file
160
tests/test_start_tab.py
Normal file
@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from app.services.update_service import UpdateError, UpdateInfo
|
||||
from app.widgets.start_tab import StartTab, _UpdateCheckThread, _UpdateDownloadThread
|
||||
|
||||
|
||||
class StartTabUpdateTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls._app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_update_button_is_below_settings_and_hidden_initially(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
|
||||
self.assertTrue(tab._update_btn.isHidden())
|
||||
self.assertLess(
|
||||
tab._settings_column_layout.indexOf(tab._settings_btn),
|
||||
tab._settings_column_layout.indexOf(tab._update_btn),
|
||||
)
|
||||
tab.shutdown()
|
||||
|
||||
def test_available_update_shows_download_button(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
info = UpdateInfo(
|
||||
tag="v2.1.0",
|
||||
version="2.1.0",
|
||||
asset_name="plate_config_app-windows-x86_64.zip",
|
||||
download_url="https://git.robotail.pro/download/test",
|
||||
)
|
||||
|
||||
tab._on_update_check_done(info)
|
||||
|
||||
self.assertFalse(tab._update_btn.isHidden())
|
||||
self.assertEqual("Скачать обновление", tab._update_btn.text())
|
||||
self.assertIn("v2.1.0", tab._update_btn.toolTip())
|
||||
tab.shutdown()
|
||||
|
||||
def test_no_update_hides_previously_visible_button(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
tab._on_update_check_done(self._update_info())
|
||||
|
||||
tab._on_update_check_done(None)
|
||||
|
||||
self.assertTrue(tab._update_btn.isHidden())
|
||||
self.assertIsNone(tab._available_update)
|
||||
tab.shutdown()
|
||||
|
||||
def test_progress_updates_button_text(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
|
||||
tab._on_update_progress(57)
|
||||
|
||||
self.assertEqual("Скачивание 57%", tab._update_btn.text())
|
||||
tab.shutdown()
|
||||
|
||||
def test_download_failure_resets_button_and_warns(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
tab._update_btn.setEnabled(False)
|
||||
tab._update_btn.setText("Скачивание 42%")
|
||||
|
||||
with patch("app.widgets.start_tab.QMessageBox.warning") as warning:
|
||||
tab._on_update_failed("ошибка сети")
|
||||
|
||||
self.assertTrue(tab._update_btn.isEnabled())
|
||||
self.assertEqual("Скачать обновление", tab._update_btn.text())
|
||||
warning.assert_called_once()
|
||||
self.assertIn("ошибка сети", warning.call_args.args[2])
|
||||
tab.shutdown()
|
||||
|
||||
def test_launch_failure_removes_download_and_warns(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
downloaded = Path(directory) / ".downloaded-update"
|
||||
downloaded.write_bytes(b"MZupdate")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.widgets.start_tab.launch_update",
|
||||
side_effect=UpdateError("не удалось запустить helper"),
|
||||
),
|
||||
patch("app.widgets.start_tab.QMessageBox.warning") as warning,
|
||||
):
|
||||
tab._on_update_downloaded(downloaded)
|
||||
|
||||
self.assertFalse(downloaded.exists())
|
||||
self.assertEqual("Скачать обновление", tab._update_btn.text())
|
||||
self.assertTrue(tab._update_btn.isEnabled())
|
||||
warning.assert_called_once()
|
||||
tab.shutdown()
|
||||
|
||||
def test_successful_download_launches_update_and_closes_window(self) -> None:
|
||||
tab = StartTab(lambda: None)
|
||||
downloaded = Path("downloaded-update")
|
||||
|
||||
with (
|
||||
patch("app.widgets.start_tab.launch_update") as launcher,
|
||||
patch.object(tab, "close") as close_window,
|
||||
):
|
||||
tab._on_update_downloaded(downloaded)
|
||||
|
||||
launcher.assert_called_once_with(downloaded)
|
||||
close_window.assert_called_once_with()
|
||||
self.assertEqual("Перезапуск…", tab._update_btn.text())
|
||||
tab.shutdown()
|
||||
|
||||
def test_update_check_thread_hides_network_errors(self) -> None:
|
||||
thread = _UpdateCheckThread()
|
||||
results: list[UpdateInfo | None] = []
|
||||
thread.result_ready.connect(results.append)
|
||||
|
||||
with patch(
|
||||
"app.widgets.start_tab.check_for_update",
|
||||
side_effect=UpdateError("offline"),
|
||||
):
|
||||
thread.run()
|
||||
|
||||
self.assertEqual([None], results)
|
||||
|
||||
def test_download_thread_emits_progress_and_result(self) -> None:
|
||||
info = self._update_info()
|
||||
result_path = Path("downloaded-update")
|
||||
thread = _UpdateDownloadThread(info, Path("."))
|
||||
progress_values: list[int] = []
|
||||
results: list[Path] = []
|
||||
thread.progress_changed.connect(progress_values.append)
|
||||
thread.result_ready.connect(results.append)
|
||||
|
||||
def fake_download(_info, _directory, *, progress, cancelled):
|
||||
self.assertFalse(cancelled())
|
||||
progress(75)
|
||||
return result_path
|
||||
|
||||
with patch("app.widgets.start_tab.download_update", side_effect=fake_download):
|
||||
thread.run()
|
||||
|
||||
self.assertEqual([75], progress_values)
|
||||
self.assertEqual([result_path], results)
|
||||
|
||||
@staticmethod
|
||||
def _update_info() -> UpdateInfo:
|
||||
return UpdateInfo(
|
||||
tag="v2.1.0",
|
||||
version="2.1.0",
|
||||
asset_name="plate_config_app-windows-x86_64.zip",
|
||||
download_url="https://git.robotail.pro/download/test",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
716
tests/test_update_service.py
Normal file
716
tests/test_update_service.py
Normal file
@ -0,0 +1,716 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services import update_service
|
||||
from app.services.update_service import (
|
||||
UpdateCancelled,
|
||||
UpdateError,
|
||||
UpdateInfo,
|
||||
archive_executable_name,
|
||||
build_download_url,
|
||||
check_for_update,
|
||||
download_update,
|
||||
extract_release_tag,
|
||||
launch_update,
|
||||
normalize_version,
|
||||
parse_version,
|
||||
platform_asset_name,
|
||||
platform_executable_name,
|
||||
signal_update_ready,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
data: bytes = b"",
|
||||
*,
|
||||
content_length: int | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._stream = io.BytesIO(data)
|
||||
self.headers: dict[str, str] = {}
|
||||
if content_length is not None:
|
||||
self.headers["Content-Length"] = str(content_length)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
return None
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._url
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._stream.read(size)
|
||||
|
||||
|
||||
def make_update_archive(
|
||||
executable_name: str,
|
||||
executable_data: bytes,
|
||||
*,
|
||||
extra_files: dict[str, bytes] | None = None,
|
||||
) -> bytes:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(executable_name, executable_data)
|
||||
for name, data in (extra_files or {}).items():
|
||||
archive.writestr(name, data)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
class UpdateServiceTests(unittest.TestCase):
|
||||
def test_normalizes_version_prefix_and_whitespace(self) -> None:
|
||||
self.assertEqual("2.0.0", normalize_version(" v2.0.0 "))
|
||||
self.assertEqual("2.0.0", normalize_version("V2.0.0"))
|
||||
|
||||
def test_parses_version_into_numeric_components(self) -> None:
|
||||
self.assertEqual((12, 34, 56), parse_version("v12.34.56"))
|
||||
self.assertEqual((2, 0, 0), parse_version(" 2.0.0 "))
|
||||
|
||||
def test_rejects_non_standard_versions(self) -> None:
|
||||
invalid_versions = (
|
||||
"v2.0",
|
||||
"v2.0.0.1",
|
||||
"v2.0.0-beta",
|
||||
"v2.01.0",
|
||||
"release-2.0.0",
|
||||
)
|
||||
for version in invalid_versions:
|
||||
with self.subTest(version=version), self.assertRaises(UpdateError):
|
||||
parse_version(version)
|
||||
|
||||
def test_extracts_tag_from_latest_redirect(self) -> None:
|
||||
tag = extract_release_tag(
|
||||
"https://git.robotail.pro/Robotail/plate-config-app/releases/tag/v2.1.0"
|
||||
)
|
||||
|
||||
self.assertEqual("v2.1.0", tag)
|
||||
|
||||
def test_rejects_invalid_release_redirects(self) -> None:
|
||||
invalid_urls = (
|
||||
"https://git.robotail.pro/user/login",
|
||||
"http://git.robotail.pro/Robotail/plate-config-app/releases/tag/v2.1.0",
|
||||
"https://example.com/Robotail/plate-config-app/releases/tag/v2.1.0",
|
||||
)
|
||||
for url in invalid_urls:
|
||||
with self.subTest(url=url), self.assertRaises(UpdateError):
|
||||
extract_release_tag(url)
|
||||
|
||||
def test_build_download_url_escapes_tag_and_asset(self) -> None:
|
||||
url = build_download_url("v2.1.0 rc/1", "plate config.zip")
|
||||
|
||||
self.assertTrue(url.endswith("/v2.1.0%20rc%2F1/plate%20config.zip"))
|
||||
|
||||
def test_equal_or_older_tag_does_not_offer_update(self) -> None:
|
||||
cases = (
|
||||
("2.0.0", "v2.0.0"),
|
||||
("2.0.0", "v1.99.99"),
|
||||
("2.5.0", "v2.4.99"),
|
||||
("2.5.4", "v2.5.3"),
|
||||
)
|
||||
for current, remote in cases:
|
||||
with self.subTest(current=current, remote=remote):
|
||||
result = check_for_update(
|
||||
current,
|
||||
opener=self._release_opener(remote),
|
||||
system="Windows",
|
||||
machine="AMD64",
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_newer_major_minor_and_patch_versions_offer_zip(self) -> None:
|
||||
cases = (
|
||||
("1.9.9", "v2.0.0"),
|
||||
("2.4.9", "v2.5.0"),
|
||||
("2.5.3", "v2.5.4"),
|
||||
)
|
||||
for current, remote in cases:
|
||||
with self.subTest(current=current, remote=remote):
|
||||
result = check_for_update(
|
||||
current,
|
||||
opener=self._release_opener(remote),
|
||||
system="Windows",
|
||||
machine="x86_64",
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual("plate_config_app-windows-x86_64.zip", result.asset_name)
|
||||
self.assertTrue(result.download_url.endswith("/" + result.asset_name))
|
||||
|
||||
def test_rejects_invalid_current_or_remote_version(self) -> None:
|
||||
for current, remote in (("development", "v2.1.0"), ("2.0.0", "v2.1")):
|
||||
with self.subTest(current=current, remote=remote), self.assertRaises(
|
||||
UpdateError
|
||||
):
|
||||
check_for_update(
|
||||
current,
|
||||
opener=self._release_opener(remote),
|
||||
system="Linux",
|
||||
machine="x86_64",
|
||||
)
|
||||
|
||||
def test_wraps_network_error_during_update_check(self) -> None:
|
||||
def opener(_request, timeout: float):
|
||||
raise OSError("offline")
|
||||
|
||||
with self.assertRaisesRegex(UpdateError, "offline") as raised:
|
||||
check_for_update("2.0.0", opener=opener)
|
||||
|
||||
self.assertIsInstance(raised.exception.__cause__, OSError)
|
||||
|
||||
def test_platform_names(self) -> None:
|
||||
self.assertEqual(
|
||||
"plate_config_app-windows-x86_64.exe",
|
||||
platform_executable_name(system="Windows", machine="AMD64"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"plate_config_app-windows-x86_64.zip",
|
||||
platform_asset_name(system="Windows", machine="AMD64"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"plate_config_app-linux-x86_64.zip",
|
||||
platform_asset_name(system="Linux", machine="x86_64"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"plate_config_app-linux-x86_64",
|
||||
archive_executable_name("plate_config_app-linux-x86_64.zip"),
|
||||
)
|
||||
|
||||
def test_rejects_unsupported_platform_architecture_or_asset(self) -> None:
|
||||
with self.assertRaisesRegex(UpdateError, "архитектуру"):
|
||||
platform_asset_name(system="Linux", machine="arm64")
|
||||
with self.assertRaisesRegex(UpdateError, "платформу"):
|
||||
platform_asset_name(system="Darwin", machine="x86_64")
|
||||
with self.assertRaisesRegex(UpdateError, "Неизвестное"):
|
||||
archive_executable_name("update.zip")
|
||||
|
||||
def test_signals_restart_only_when_ready_file_is_requested(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
marker = Path(directory) / "ready"
|
||||
with patch.dict(os.environ, {update_service.UPDATE_READY_ENV: str(marker)}):
|
||||
signal_update_ready()
|
||||
self.assertEqual(str(os.getpid()), marker.read_text(encoding="ascii"))
|
||||
self.assertNotIn(update_service.UPDATE_READY_ENV, os.environ)
|
||||
marker.unlink()
|
||||
signal_update_ready()
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_downloads_and_validates_windows_zip(self) -> None:
|
||||
info = self._update_info()
|
||||
data = make_update_archive(
|
||||
"plate_config_app-windows-x86_64.exe",
|
||||
b"MZnew",
|
||||
extra_files={"commands/default.csv": b"04;Update"},
|
||||
)
|
||||
progress_values: list[int] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = download_update(
|
||||
info,
|
||||
Path(directory),
|
||||
opener=lambda _request, timeout: FakeResponse(
|
||||
info.download_url,
|
||||
data,
|
||||
content_length=len(data),
|
||||
),
|
||||
progress=progress_values.append,
|
||||
)
|
||||
|
||||
self.assertEqual(data, result.read_bytes())
|
||||
self.assertEqual(100, progress_values[-1])
|
||||
|
||||
def test_downloads_and_validates_linux_zip(self) -> None:
|
||||
info = self._update_info(system="linux")
|
||||
data = make_update_archive("plate_config_app-linux-x86_64", b"\x7fELFnew")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = download_update(
|
||||
info,
|
||||
Path(directory),
|
||||
opener=lambda _request, timeout: FakeResponse(info.download_url, data),
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(result) as archive:
|
||||
self.assertEqual(
|
||||
["plate_config_app-linux-x86_64"],
|
||||
archive.namelist(),
|
||||
)
|
||||
|
||||
def test_rejects_invalid_or_incomplete_download(self) -> None:
|
||||
info = self._update_info()
|
||||
valid_data = make_update_archive(
|
||||
"plate_config_app-windows-x86_64.exe",
|
||||
b"MZnew",
|
||||
)
|
||||
cases = (
|
||||
(b"<html>login</html>", None),
|
||||
(valid_data, len(valid_data) + 10),
|
||||
(b"", None),
|
||||
)
|
||||
for data, content_length in cases:
|
||||
with self.subTest(size=len(data)), tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory)
|
||||
with self.assertRaises(UpdateError):
|
||||
download_update(
|
||||
info,
|
||||
target,
|
||||
opener=lambda _request, timeout, payload=data, length=content_length: (
|
||||
FakeResponse(
|
||||
info.download_url,
|
||||
payload,
|
||||
content_length=length,
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual([], list(target.iterdir()))
|
||||
|
||||
def test_rejects_login_or_foreign_download_redirect(self) -> None:
|
||||
info = self._update_info()
|
||||
data = make_update_archive(
|
||||
"plate_config_app-windows-x86_64.exe",
|
||||
b"MZnew",
|
||||
)
|
||||
redirects = (
|
||||
"https://git.robotail.pro/user/login",
|
||||
"https://example.com/update.zip",
|
||||
)
|
||||
for redirect in redirects:
|
||||
with self.subTest(redirect=redirect), tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaises(UpdateError):
|
||||
download_update(
|
||||
info,
|
||||
Path(directory),
|
||||
opener=lambda _request, timeout, url=redirect: FakeResponse(
|
||||
url,
|
||||
data,
|
||||
),
|
||||
)
|
||||
|
||||
def test_rejects_oversized_downloads(self) -> None:
|
||||
info = self._update_info()
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
update_service,
|
||||
"MAX_DOWNLOAD_BYTES",
|
||||
4,
|
||||
):
|
||||
for content_length in (8, None):
|
||||
with self.subTest(content_length=content_length), self.assertRaisesRegex(
|
||||
UpdateError,
|
||||
"превышает",
|
||||
):
|
||||
download_update(
|
||||
info,
|
||||
Path(directory),
|
||||
opener=lambda _request, timeout, length=content_length: FakeResponse(
|
||||
info.download_url,
|
||||
b"12345678",
|
||||
content_length=length,
|
||||
),
|
||||
)
|
||||
|
||||
def test_cancels_download_and_removes_partial_file(self) -> None:
|
||||
info = self._update_info()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory)
|
||||
with self.assertRaises(UpdateCancelled):
|
||||
download_update(
|
||||
info,
|
||||
target,
|
||||
opener=lambda _request, timeout: FakeResponse(
|
||||
info.download_url,
|
||||
b"partial",
|
||||
),
|
||||
cancelled=lambda: True,
|
||||
)
|
||||
self.assertEqual([], list(target.iterdir()))
|
||||
|
||||
def test_rejects_missing_or_read_only_target_directory(self) -> None:
|
||||
info = self._update_info()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(UpdateError, "не найден"):
|
||||
download_update(info, Path(directory) / "missing")
|
||||
with patch("app.services.update_service.os.access", return_value=False):
|
||||
with self.assertRaisesRegex(UpdateError, "Нет прав"):
|
||||
download_update(info, Path(directory))
|
||||
|
||||
def test_rejects_archive_without_executable(self) -> None:
|
||||
data = self._custom_archive({"commands/default.csv": b"04;Update"})
|
||||
|
||||
self._assert_invalid_archive(data, "исполняемый файл")
|
||||
|
||||
def test_rejects_archive_path_traversal(self) -> None:
|
||||
data = self._custom_archive(
|
||||
{
|
||||
"plate_config_app-windows-x86_64.exe": b"MZnew",
|
||||
"../outside.txt": b"unsafe",
|
||||
}
|
||||
)
|
||||
|
||||
self._assert_invalid_archive(data, "небезопасный путь")
|
||||
|
||||
def test_rejects_archive_symlink(self) -> None:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w") as archive:
|
||||
archive.writestr("plate_config_app-windows-x86_64.exe", b"MZnew")
|
||||
link = zipfile.ZipInfo("commands/link")
|
||||
link.create_system = 3
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(link, "../../outside")
|
||||
|
||||
self._assert_invalid_archive(stream.getvalue(), "ссылку")
|
||||
|
||||
def test_rejects_duplicate_archive_paths(self) -> None:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w") as archive:
|
||||
archive.writestr("plate_config_app-windows-x86_64.exe", b"MZnew")
|
||||
archive.writestr("commands/Test.csv", b"one")
|
||||
archive.writestr("commands/test.csv", b"two")
|
||||
|
||||
self._assert_invalid_archive(stream.getvalue(), "повторяющиеся")
|
||||
|
||||
def test_rejects_excessive_unpacked_size(self) -> None:
|
||||
data = make_update_archive(
|
||||
"plate_config_app-windows-x86_64.exe",
|
||||
b"MZnew",
|
||||
extra_files={"commands/large.csv": b"12345678"},
|
||||
)
|
||||
with patch.object(update_service, "MAX_UNPACKED_BYTES", 8):
|
||||
self._assert_invalid_archive(data, "Распакованный размер")
|
||||
|
||||
def test_prepares_windows_package_replacement_helper(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app-windows-x86_64.exe"
|
||||
archive = root / ".downloaded-update.zip"
|
||||
target.write_bytes(b"MZold")
|
||||
archive.write_bytes(
|
||||
make_update_archive(
|
||||
target.name,
|
||||
b"MZnew",
|
||||
extra_files={"commands/default.csv": b"new"},
|
||||
)
|
||||
)
|
||||
|
||||
with patch("app.services.update_service.subprocess.Popen") as popen:
|
||||
launch_update(
|
||||
archive,
|
||||
executable_path=target,
|
||||
process_id=123,
|
||||
system="Windows",
|
||||
)
|
||||
|
||||
scripts = list(root.glob(".plate_config_updater-*.ps1"))
|
||||
staging = list(root.glob(".plate_config_update-*"))
|
||||
self.assertEqual(1, len(scripts))
|
||||
self.assertEqual(1, len(staging))
|
||||
self.assertFalse(archive.exists())
|
||||
self.assertEqual(b"new", (staging[0] / "commands/default.csv").read_bytes())
|
||||
script = scripts[0].read_text(encoding="utf-8-sig")
|
||||
self.assertIn("$Staging", script)
|
||||
self.assertIn("$backedUp", script)
|
||||
self.assertIn("AddSeconds(30)", script)
|
||||
self.assertIn("$restarted.HasExited", script)
|
||||
self.assertIn("$ReadyFile", script)
|
||||
self.assertIn('$env:PYINSTALLER_RESET_ENVIRONMENT = "1"', script)
|
||||
popen.assert_called_once()
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
|
||||
def test_prepares_linux_package_replacement_helper(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app-linux-x86_64"
|
||||
archive = root / ".downloaded-update.zip"
|
||||
target.write_bytes(b"\x7fELFold")
|
||||
archive.write_bytes(
|
||||
make_update_archive(
|
||||
target.name,
|
||||
b"\x7fELFnew",
|
||||
extra_files={"commands/default.csv": b"new"},
|
||||
)
|
||||
)
|
||||
|
||||
with patch("app.services.update_service.subprocess.Popen") as popen:
|
||||
launch_update(
|
||||
archive,
|
||||
executable_path=target,
|
||||
process_id=123,
|
||||
system="Linux",
|
||||
)
|
||||
|
||||
scripts = list(root.glob(".plate_config_updater-*.sh"))
|
||||
staging = list(root.glob(".plate_config_update-*"))
|
||||
self.assertEqual(1, len(scripts))
|
||||
self.assertEqual(1, len(staging))
|
||||
self.assertTrue((staging[0] / target.name).stat().st_mode & stat.S_IXUSR)
|
||||
script = scripts[0].read_text(encoding="utf-8")
|
||||
self.assertIn("staging_path", script)
|
||||
self.assertIn("rollback", script)
|
||||
self.assertIn("PLATE_CONFIG_UPDATE_READY_FILE", script)
|
||||
self.assertIn("PYINSTALLER_RESET_ENVIRONMENT=1", script)
|
||||
popen.assert_called_once()
|
||||
|
||||
def test_extracts_external_archive_next_to_executable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
downloads = root / "downloads"
|
||||
application = root / "application"
|
||||
downloads.mkdir()
|
||||
application.mkdir()
|
||||
target = application / "plate_config_app-linux-x86_64"
|
||||
archive = downloads / "update.zip"
|
||||
target.write_bytes(b"\x7fELFold")
|
||||
archive.write_bytes(make_update_archive(target.name, b"\x7fELFnew"))
|
||||
|
||||
with patch("app.services.update_service.subprocess.Popen") as popen:
|
||||
launch_update(
|
||||
archive,
|
||||
executable_path=target,
|
||||
process_id=123,
|
||||
system="Linux",
|
||||
)
|
||||
|
||||
staging = list(application.glob(".plate_config_update-*"))
|
||||
self.assertEqual(1, len(staging))
|
||||
self.assertFalse(archive.exists())
|
||||
self.assertIn(str(staging[0]), popen.call_args.args[0])
|
||||
|
||||
def test_failed_helper_launch_removes_staging_and_script(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app-windows-x86_64.exe"
|
||||
archive = root / "update.zip"
|
||||
target.write_bytes(b"MZold")
|
||||
archive.write_bytes(make_update_archive(target.name, b"MZnew"))
|
||||
|
||||
with patch(
|
||||
"app.services.update_service.subprocess.Popen",
|
||||
side_effect=OSError("helper failed"),
|
||||
), self.assertRaisesRegex(OSError, "helper failed"):
|
||||
launch_update(
|
||||
archive,
|
||||
executable_path=target,
|
||||
process_id=123,
|
||||
system="Windows",
|
||||
)
|
||||
|
||||
self.assertEqual([], list(root.glob(".plate_config_update-*")))
|
||||
self.assertEqual([], list(root.glob(".plate_config_updater-*")))
|
||||
|
||||
def test_rejects_missing_archive_or_unsupported_platform(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app"
|
||||
archive = root / "update.zip"
|
||||
target.write_bytes(b"old")
|
||||
|
||||
with self.assertRaisesRegex(UpdateError, "не найден"):
|
||||
launch_update(archive, executable_path=target, system="Linux")
|
||||
|
||||
archive.write_bytes(b"not-a-zip")
|
||||
with self.assertRaisesRegex(UpdateError, "платформу"):
|
||||
launch_update(archive, executable_path=target, system="Darwin")
|
||||
|
||||
def _assert_invalid_archive(self, data: bytes, message: str) -> None:
|
||||
info = self._update_info()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory)
|
||||
with self.assertRaisesRegex(UpdateError, message):
|
||||
download_update(
|
||||
info,
|
||||
target,
|
||||
opener=lambda _request, timeout: FakeResponse(
|
||||
info.download_url,
|
||||
data,
|
||||
),
|
||||
)
|
||||
self.assertEqual([], list(target.iterdir()))
|
||||
|
||||
@staticmethod
|
||||
def _custom_archive(files: dict[str, bytes]) -> bytes:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, data in files.items():
|
||||
archive.writestr(name, data)
|
||||
return stream.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _update_info(*, system: str = "windows") -> UpdateInfo:
|
||||
asset_name = f"plate_config_app-{system}-x86_64.zip"
|
||||
return UpdateInfo(
|
||||
tag="v2.1.0",
|
||||
version="2.1.0",
|
||||
asset_name=asset_name,
|
||||
download_url=(
|
||||
"https://git.robotail.pro/Robotail/plate-config-app/releases/"
|
||||
f"download/v2.1.0/{asset_name}"
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _release_opener(tag: str):
|
||||
def opener(_request, timeout: float):
|
||||
return FakeResponse(
|
||||
"https://git.robotail.pro/Robotail/plate-config-app/"
|
||||
f"releases/tag/{tag}"
|
||||
)
|
||||
|
||||
return opener
|
||||
|
||||
|
||||
class ReplacementIntegrationTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_rollback(
|
||||
target: Path,
|
||||
expected_digest: str,
|
||||
replaced_resource: Path,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
if (
|
||||
target.is_file()
|
||||
and ReplacementIntegrationTests._digest(target) == expected_digest
|
||||
and replaced_resource.is_file()
|
||||
and replaced_resource.read_text(encoding="utf-8") == "old"
|
||||
and not list(target.parent.glob(".plate_config_updater-*"))
|
||||
and not list(target.parent.glob(".plate_config_update-*"))
|
||||
and not list(target.parent.glob(".plate_config_backup-*"))
|
||||
and (target.parent / "update-error.log").is_file()
|
||||
):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise AssertionError("Внешний helper не откатил пакет за 20 секунд")
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows")
|
||||
def test_windows_helper_rolls_back_when_new_app_exits_early(self) -> None:
|
||||
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app-windows-x86_64.exe"
|
||||
archive = root / "update.zip"
|
||||
new_executable = (system_root / "System32" / "whoami.exe").read_bytes()
|
||||
shutil.copy2(system_root / "System32" / "where.exe", target)
|
||||
self._prepare_old_files(root)
|
||||
archive.write_bytes(
|
||||
make_update_archive(
|
||||
target.name,
|
||||
new_executable,
|
||||
extra_files={"commands/version.txt": b"new"},
|
||||
)
|
||||
)
|
||||
expected_digest = self._digest(target)
|
||||
processes = self._launch_and_capture(archive, target, "Windows")
|
||||
|
||||
self._wait_for_rollback(
|
||||
target,
|
||||
expected_digest,
|
||||
root / "commands/version.txt",
|
||||
)
|
||||
processes[0].wait(timeout=5)
|
||||
self._assert_package_preserved(root)
|
||||
self._wait_for_windows_unlock(target)
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
|
||||
def test_linux_helper_rolls_back_when_new_app_exits_early(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "plate_config_app-linux-x86_64"
|
||||
archive = root / "update.zip"
|
||||
new_executable = Path("/bin/true").read_bytes()
|
||||
shutil.copy2("/bin/false", target)
|
||||
self._prepare_old_files(root)
|
||||
archive.write_bytes(
|
||||
make_update_archive(
|
||||
target.name,
|
||||
new_executable,
|
||||
extra_files={"commands/version.txt": b"new"},
|
||||
)
|
||||
)
|
||||
expected_digest = self._digest(target)
|
||||
processes = self._launch_and_capture(archive, target, "Linux")
|
||||
|
||||
self._wait_for_rollback(
|
||||
target,
|
||||
expected_digest,
|
||||
root / "commands/version.txt",
|
||||
)
|
||||
processes[0].wait(timeout=5)
|
||||
self._assert_package_preserved(root)
|
||||
self.assertTrue(target.stat().st_mode & stat.S_IXUSR)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_old_files(root: Path) -> None:
|
||||
commands = root / "commands"
|
||||
commands.mkdir()
|
||||
(commands / "version.txt").write_text("old", encoding="utf-8")
|
||||
(commands / "obsolete.txt").write_text("remove", encoding="utf-8")
|
||||
(root / "settings.json").write_text("preserve", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _assert_package_preserved(root: Path) -> None:
|
||||
self_settings = (root / "settings.json").read_text(encoding="utf-8")
|
||||
if self_settings != "preserve":
|
||||
raise AssertionError("settings.json не сохранён")
|
||||
if not (root / "commands/obsolete.txt").exists():
|
||||
raise AssertionError("Старое содержимое каталога не восстановлено")
|
||||
|
||||
@staticmethod
|
||||
def _launch_and_capture(
|
||||
archive: Path,
|
||||
target: Path,
|
||||
system: str,
|
||||
) -> list[subprocess.Popen]:
|
||||
processes: list[subprocess.Popen] = []
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def start_process(*args, **kwargs):
|
||||
process = real_popen(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
with patch(
|
||||
"app.services.update_service.subprocess.Popen",
|
||||
side_effect=start_process,
|
||||
):
|
||||
launch_update(
|
||||
archive,
|
||||
executable_path=target,
|
||||
process_id=2_147_483_647,
|
||||
system=system,
|
||||
)
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_windows_unlock(target: Path) -> None:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
target.unlink()
|
||||
return
|
||||
except PermissionError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -5,6 +5,7 @@ by: Паша Николаевич
|
||||
>[!info] Информация
|
||||
>Это раздел с информацией о плате периферийного контроллера.
|
||||
>Информацию о плате для витрины призов смотреть в [[Витрина призов]]
|
||||
>Текущая описанная прошивка: `ver_2.3.6_no_auth_prizes`.
|
||||
|
||||
>[!info] Общие сведения
|
||||
> - Плата подключается к компьютеру по переходу USB - COM порт.
|
||||
@ -17,38 +18,66 @@ by: Паша Николаевич
|
||||
2. [[#Авторизация]]
|
||||
1. [[#Алгоритм авторизации]]
|
||||
2. [[#Команды]]
|
||||
1. [[#Примечания]]
|
||||
1. [[#Список команд]]
|
||||
2. [[#Примечания]]
|
||||
1. [[#Служебные команды]]
|
||||
2. [[#Программы адресных диодов]]
|
||||
1. [[#Специфичные программы]]
|
||||
2. [[#Список команд]]
|
||||
3. [[#Обновление]]
|
||||
1. [[#Настройки адресной подсветки]]
|
||||
2. [[#Карты матриц]]
|
||||
3. [[#Специфичные программы]]
|
||||
1. [[#Отдельный раздел для призовой стойки]]
|
||||
2. [[#Отдельный раздел для Бродяги морей]]
|
||||
3. [[#Отдельный раздел для морского боя VR]]
|
||||
1. [[#Управляющие коды 85]]
|
||||
2. [[#Поведение после окончания эффекта]]
|
||||
3. [[#Direct-шкалы 8530..8534]]
|
||||
4. [[#Тесты морского боя VR 8525TVVV]]
|
||||
5. [[#Базовые эффекты 8510..8522]]
|
||||
6. [[#Спецэффекты 8540..8549]]
|
||||
7. [[#Тестовые заливки 8580..8582]]
|
||||
8. [[#Диагностика 85]]
|
||||
4. [[#Отдельный раздел для Данро]]
|
||||
1. [[#Тесты панели Данро 8625TVVV]]
|
||||
2. [[#Список эффектов]]
|
||||
5. [[#Отдельный раздел для RZ]]
|
||||
1. [[#Тесты панели RZ 8725TVVV]]
|
||||
6. [[#Дополнительные цифровые GPIO]]
|
||||
3. [[#Обновление прошивки платы]]
|
||||
1. [[#Православный новый]]
|
||||
2. [[#Пендосский старый]]
|
||||
|
||||
# Система команд
|
||||
|
||||
- Общение - строка, заканчивающаяся кодами (0xD)+(0xA), достаточно любого из них для окончания строки
|
||||
- Минимально – ДВА (2, two) символа. 00+\<Enter\> – команда, а 0+\<Enter\> – уже нет
|
||||
- Таймаут - 100 милисекунд, если строка за это время не окончилась - идет игнорирование команды
|
||||
- Максимальная длина входной строки - 199 символов
|
||||
- Таймаут - 100 миллисекунд, если строка за это время не окончилась - идет игнорирование команды
|
||||
|
||||
Команды получаемые от компьютера помещаются в очередь и выполняются последовательно, кроме команд "сброс" (00) и "очистка очереди".
|
||||
Размер очереди команд - 100 строк. Команды `00` и `90` обрабатываются сразу.
|
||||
|
||||
Шифрование обмена: двойное рукопожатие при помощи AES256 c фиксированным ключом.
|
||||
|
||||
#### Кнопка SERVICE на плате:
|
||||
- Если ее зажать и удержать при включении – выполнится сброс EEPROM
|
||||
## Кнопка SERVICE на плате:
|
||||
- Если ее зажать и удержать при включении – выполнится сброс настроек контроллера
|
||||
- При работе – посылает команду «SERVICE – предлагая компьютеру перейти в режим настройки игровой программы
|
||||
|
||||
>[!warning] Внимание!
|
||||
>Имеются два режима работы: SILENT (тихий режим) и DEVELOPER (режим разработки, с полными логами).
|
||||
>
|
||||
>Переключение режимов:
|
||||
>Если соединить ногу GPIO_15 с землей через резистор <10кОм включается ТИХИЙ режим, и контроллер не выдает логи, а только необходимые ответы и команды.
|
||||
>Также режим можно переключать командой «01» (временно, в релизе будет удалено)
|
||||
>Команда `01` временно переключает режим логирования. В релизной версии команда может быть удалена.
|
||||
|
||||
### Авторизация
|
||||
## Авторизация
|
||||
|
||||
После загрузки контроллер не принимает никаких команд, кроме запросов на авторизацию.
|
||||
|
||||
После открытия COM-порта программа выполняет штатный аппаратный сброс контроллера
|
||||
коротким импульсом RTS и затем ожидает отдельную строку
|
||||
`periphery controller ver_<номер версии и комментарий>`. До её получения программа
|
||||
не отправляет контроллеру команды. После получения строки программа выдерживает паузу
|
||||
`2000 мс` и только затем начинает авторизацию.
|
||||
|
||||
Запрос – требование зашифровать строку шестнадцатеричных символов с помощью AES256 алгоритма, используя известный ключ.
|
||||
|
||||
Компьютер генерирует случайную последовательность из 16 байт и отправляет ее контроллеру на шифрование. Тот ее шифрует и отвечает. Компьютер расшифровывает и проверяет на совпадение.
|
||||
@ -70,14 +99,60 @@ by: Паша Николаевич
|
||||
4. **Контроллер ответит на первый запрос компа**:
|
||||
**RAES\<symbols\> (16 байт или 32 HEX-символа)**
|
||||
***Пример:*** **RAES***99ED731648AB8C1709CC4C3C8C3BB0E1*
|
||||
5. Контроллер может отправить промежуточные ответы `AUTH_PC_OK` и статус.
|
||||
6. Авторизация считается полностью завершённой только после получения отдельной точной
|
||||
строки `READY!`. Ответы `RAES`, `AUTH_PC_OK` и любые другие строки сами по себе не
|
||||
означают успешного завершения авторизации.
|
||||
|
||||
# Команды
|
||||
|
||||
## Список команд
|
||||
| Команда | Назначение, примеры, ответы |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [[#Служебные команды\|00]] | Перезагрузить контроллер (RESET) |
|
||||
| [[#Служебные команды\|01]] | Отладочная. включает-выключает подробные логи. В релизе будет отключена |
|
||||
| [[#Служебные команды\|02]] | Подготовиться к приему файла. освободить место, отформатировать встроенную файловую, проверить хватит ли места. <br>_**Например**_, `02356987` - заявка на файл обновления размером 356987 байт.<br>Выполняется около 15 секунд, после чего весь прием данных в порту заносится в файл "update.bin".<br>Ответ при приеме файла - каждый блок (4096) - размер принятого.<br>По окончании - переход снова в режим команд (подробнее – [Обновление](#Обновление)). |
|
||||
| [[#Служебные команды\|03]] | Показать содержимое SPIFFS |
|
||||
| [[#Служебные команды\|04]] | Запустить ОТА обновление с ранее записанным файлом SPIFFS: `/update.bin` |
|
||||
| [[#Служебные команды\|05]] | Показать версию прошивки (указана в дефолтах) |
|
||||
| [[#Служебные команды\|06]] | Показать MAC-адрес (без двоеточий, как в оригинале), уникальный, можно считать серийным номером |
|
||||
| [[#Авторизация\|07]] | Безопасность (запрос авторизации от компа. На этот запрос создается рандомный запрос навстречу. См. [[#Алгоритм авторизации]]) |
|
||||
| [[#Авторизация\|08]] | Безопасность (прием ответа от компьютера на запрос `07`. См. [[#Алгоритм авторизации]]) |
|
||||
| [[#Служебные команды\|09]] | Сброс EEPROM (параметром - MAC без двоеточий, иначе не сбросится) |
|
||||
| 10 | Подтверждение принятия сигнала COIN, иначе этот сигнал будет посылаться снова с периодичностью `200 мс` (сколько приняли койнов, столько и подтверждений быть должно) |
|
||||
| 20X | Установить состояние выходов - всего выходов 8, давать их двоичное состояние.<br>_**Например**_, `203` - вывести 1 и 2 выходы по единичке, остальные - нолики. Соответственно, выход на разъеме будет "прижат" к земле. |
|
||||
| 21XY | Установить состояние выхода номер Х в -> Y \[0,1,2(инверсия)\].<br>_**Примеры:**_<br>`2131` - Включить 3 по индексу выход<br>`2170` - Выключить 7 по индексу выход |
|
||||
| [[#Отдельный раздел для призовой стойки\|25CCB]] | Витрина призов (см. [[#Отдельный раздел для призовой стойки]]): <br>`25` - запрос состояния; <br>`2501B` - выдать приз из ячейки `B`; <br>`2511B` - открыть ячейку `B`; <br>`25991`/`25990` - включить/выключить авторежим витрины. |
|
||||
| 30X | Хоппер 1.<br>`301` - Включить мотор хоппера<br>`300` - Выключить мотор хоппера<br>`309` - Выдать приз (в авторежиме[^1]) |
|
||||
| 40X | Хоппер 2.<br>Аналогично |
|
||||
| [[#Дополнительные цифровые GPIO\|50CCP]] | Дополнительные цифровые GPIO25/GPIO26 (см. [[#Дополнительные цифровые GPIO]]): <br>`50` - запрос состояния; <br>`5001P` - конфигурация входа; <br>`5005P` - запрос уровня входа; <br>`5010P` - установка выходов. |
|
||||
| [[#Программы адресных диодов\|8XYY[PARAMS]]] | Выбор программы адресных диодов `YY` по каналу `X`. <br>Для программы `08` дополнительно передается цвет `RRRGGGBBB`, для `8012..`/`8013..` - direct-группы из проекта 1 на 4-й ленте, где принимаются только `00` и `01..03` (см. [[#Программы адресных диодов]]). |
|
||||
| [[#Отдельный раздел для морского боя VR\|85XX[SSS]]] | Эффекты активной VM-матрицы (выбирается командой `96`). <br>**Для матрицы `s_b`:** <br>управляющие `00`/`01`;<br>тесты `25`;<br>direct-шкалы `30..34`;<br>базовые `10..22`;<br>спец. `40..49`;<br>заливки `80..82`.<br><br>**Для `dan_ro`/`rz`:**<br>эффекты `51..56`;<br>группа `8550XX` принудительно задаёт `dan_ro`.<br>`SSS` — длительность в секундах (см. [[#Отдельный раздел для морского боя VR]]). |
|
||||
| [[#Программы адресных диодов\|85]] | Без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память) |
|
||||
| [[#Отдельный раздел для Данро\|8550XX[SSS]]] | Данро (принудительная матрица `dan_ro`): <br>`855000` — очистить экран; <br>`855001` — автокарусель вкл;<br>эффекты `855010..855016`;<br>`SSS` — длительность в секундах (см. [[#Отдельный раздел для Данро]]). |
|
||||
| [[#Отдельный раздел для RZ\|8560XX[SSS]]] | Резервная панель RZ (принудительная матрица `rz`): <br>`856000` — очистить экран; <br>`856001` — автокарусель вкл; <br>эффекты `856010..856016`; <br>`SSS` — длительность в секундах (см. [[#Отдельный раздел для резервной панели RZ]]). |
|
||||
| [[#Отдельный раздел для RZ\|96rz + 85XX[SSS]]] | Резервная панель RZ: <br>сначала `96rz` — выбрать матрицу, затем `85` — стоп; <br>`8500`/`8501` — авто выкл/вкл; <br>эффекты `8551..8556`; тесты `8525TVVV` (см. [[#Отдельный раздел для резервной панели RZ]]). |
|
||||
| [[#Настройки адресной подсветки\|89CKP]] | Настройки WS2812: <br>`C=0` - все 4 ленты, <br>`C=1/2/3` - 2-й/3-й/4-й физический канал; <br>`K=0` - порядок цветов (`0` RGB, `1` RBG, `2` BRG, `3` BGR, `4` GRB, `5` GBR), <br>`K=1` - яркость `0..255`, <br>`K=2` - гамма `0/1`.<br>***Примеры:*** <br>`8901255` - яркость всех каналов 255; <br>`89021` - включить гамму; <br>`89304` - порядок GRB для 4-го физического канала. |
|
||||
| 90 | Очистить очередь команд |
|
||||
| 91 | Запрос состояния цифровых входов, их всего 24(+2 входа хоппера), ответ в двоичном коде.<br>Для уменьшения объема передаваемой информации, преобразуется в десятичное число<br>_**Примеры:**_<br>`INPUT67108863` (`11111111111111111111111111`) - все кнопки не нажаты<br>`INPUT67108861` (`11111111111111111111111101`) - нажата вторая кнопка |
|
||||
| 91X | _Дополнительно_: задать время антидребезга платы (`10..100` для штатного использования; по умолчанию 50 мс).<br>_**Например**_, `9110` - установить антидребезг на 10 мс |
|
||||
| 92 | Запрос значения аналоговых входов **(отключает автовывод)** - ответ в формате `ADC0,0,0,0`.<br>Всего 4 аналоговых входа |
|
||||
| 92X | Периодический автовывод значений аналоговых входов.<br>_**Например**_, `92100` - каждые 100 милисекунд выдавать значения ADC (`50..5000`) |
|
||||
| 93 | Запрос `global_summa` (число коинов, нестираемо) |
|
||||
| [[#Карты матриц\|96]] | Без параметра — запрос текущей активной VM-матрицы (ответ: `MATRIX:<name> ROWS:<n> COLS:<n>`).<br>С параметром — выбор и сохранение матрицы в NVS.<br> Список доступных карт см. [[#Карты матриц]].<br>***Примеры:***<br> `96s_b` — морской бой;<br> `96dan_ro` — Данро;<br> `96rz` — резервная панель. |
|
||||
| 97 | Показать сохранённое в NVS имя активной матрицы.<br>***Формат вывода:*** `ROLE : <имя>` (до 20 символов). |
|
||||
| 98 | Запрос загруженности ядер, оперативки, свободного места в памяти |
|
||||
| 99 | Запрос статуса |
|
||||
|
||||
|
||||
[^1]: Авторежим: если датчик в закрытом состоянии - на полсекунды включить мотор, ждать сработки датчика либо таймаута. Если таймаут - выдать сообщение "HOPPER1 ERROR" и наглухо повесить процесс хоппера до перезагрузки. Иначе - сообщение "HOPPER1 OK"
|
||||
|
||||
|
||||
## Примечания
|
||||
|
||||
### Служебные команды
|
||||
|
||||
`02…09` – команды относятся к служебным (обновление прошивки и двойное рукопожопие)
|
||||
`02…09` – команды относятся к служебным (обновление прошивки и двойное рукопожатие)
|
||||
|
||||
>[!warning] Внимание!
|
||||
>Перешивается только основная программа, не трогается таблица разделов и буты.
|
||||
@ -87,88 +162,354 @@ by: Паша Николаевич
|
||||
|
||||
### Программы адресных диодов
|
||||
|
||||
`8XYY` - выбор программы адресных диодов
|
||||
|
||||
Всего 4 канала
|
||||
|
||||
- `81YY…84YY` - переключить программу на канале
|
||||
***Например:*** `8204` – на второй канал вывести четвертую, "зеленую" программу
|
||||
- `80YY` – переключает программу на всех каналах сразу.
|
||||
`8XYY[PARAMS]` - выбор программы адресных диодов, где X - номер канала в формате одной цифры, а YY - номер параметра базового эффекта.
|
||||
|
||||
Программа зависит от автомата и не может быть унифицирована. Именно для этого требуется обновление прошивки контроллера.
|
||||
|
||||
Кроме, пожалуй, этих (считать тестовыми. Остальное – добавлять по вкусу):
|
||||
- 0 – ничего не выводить в канал
|
||||
- 1 – повторный периодический вывод буфера
|
||||
- 2 – погасить (выводить нолики на всю длину)
|
||||
- 3 – красный
|
||||
- 4 – зеленый
|
||||
- 5 - синий
|
||||
- 6 - серый (100, 100, 100)
|
||||
- 7 - белый
|
||||
- 9 - выводить ЛГБТ переливы
|
||||
***Список базовых эффектов:***
|
||||
- `00` – ничего не выводить в канал
|
||||
- `01` – повторный периодический вывод буфера
|
||||
- `02` – погасить (выводить нолики на всю длину)
|
||||
- `03` – красный
|
||||
- `04` – зеленый
|
||||
- `05` - синий
|
||||
- `06` - серый (100, 100, 100)
|
||||
- `07` - белый
|
||||
- `08RRRGGGBBB` - пользовательский цвет, каждая компонента 3 десятичные цифры (`000..255`)
|
||||
- `09` - выводить ЛГБТ переливы
|
||||
- `10` - морская волна для шапки морского боя
|
||||
- `11` - автоперебор медленных эффектов шапки морского боя
|
||||
|
||||
*10 и далее – пользовательские функции*
|
||||
Для бродяг морей 4 канал - монетник и выдача приза:
|
||||
`8012хх`
|
||||
`8013хх`
|
||||
|
||||
- `1200` - очистить обе direct-группы на 4-й ленте; `1201..1203` - direct-группа 1, эффекты 1..3
|
||||
- `1300` - очистить обе direct-группы на 4-й ленте; `1301..1303` - direct-группа 2, эффекты 1..3
|
||||
|
||||
#### Настройки адресной подсветки
|
||||
|
||||
`89CKP` меняет настройки вывода WS2812. Это не выбор эффекта, а настройка того, как уже рассчитанный цвет будет записан в буфер конкретной ленты:
|
||||
- `C` - канал: `0` для всех каналов подсветки, `0..3` для конкретного канала;
|
||||
- `K` - настройка: `0` порядок цветов, `1` яркость, `2` гамма-коррекция;
|
||||
- `P` - значение настройки. Может состоять из нескольких цифр, например `255` для яркости. Если отправить только `89CK` без `P`, параметр будет принят как `0`, поэтому лучше передавать `P` явно.
|
||||
|
||||
| `K` | `P` | Что меняет | Сохранение |
|
||||
| :-: | :------- | :------------------------------------------- | :--------------------- |
|
||||
| `0` | `0..5` | Порядок цветов в буфере WS2812 | Сохраняется в NVS |
|
||||
| `1` | `0..255` | Яркость ленты | Только до перезагрузки |
|
||||
| `2` | `0/1` | Гамма-коррекция: `1` включить, `0` выключить | Только до перезагрузки |
|
||||
|
||||
Порядки цвета для `K=0`:
|
||||
|
||||
| `P` | Порядок |
|
||||
|---|---|
|
||||
| `0` | RGB |
|
||||
| `1` | RBG |
|
||||
| `2` | BRG |
|
||||
| `3` | BGR |
|
||||
| `4` | GRB |
|
||||
| `5` | GBR |
|
||||
|
||||
Для проверки удобно включать тестовые цвета:
|
||||
|
||||
- `8003` - все ленты красным;
|
||||
- `8004` - все ленты зеленым;
|
||||
- `8005` - все ленты синим.
|
||||
|
||||
Если какая-то лента показывает цвета неверно, поменяйте ее порядок через `89C0P`.
|
||||
|
||||
###### Примеры порядка цветов
|
||||
|
||||
| Команда | Что делает |
|
||||
| :--- | :--- |
|
||||
| `89004` | Установить `GRB` для всех лент |
|
||||
| `89104` | Установить `GRB` для 2-го физического канала |
|
||||
| `89204` | Установить `GRB` для 3-го физического канала |
|
||||
| `89304` | Установить `GRB` для 4-го физического канала |
|
||||
| `89000` | Установить `RGB` для всех лент |
|
||||
| `89300` | Установить `RGB` для 4-го физического канала |
|
||||
|
||||
###### Примеры яркости и гаммы
|
||||
|
||||
| Команда | Что делает |
|
||||
| :-------- | :--------------------------------------------------- |
|
||||
| `8901255` | Яркость `255` для всех лент |
|
||||
| `8901128` | Яркость `128` для всех лент |
|
||||
| `8931128` | Яркость `128` для 4-го физического канала |
|
||||
| `89021` | Включить гамма-коррекцию для всех лент |
|
||||
| `89020` | Выключить гамма-коррекцию для всех лент |
|
||||
| `89321` | Включить гамма-коррекцию для 4-го физического канала |
|
||||
| | |
|
||||
|
||||
#### Карты матриц
|
||||
|
||||
Выбор карты командой `96<имя>`, сохраняется в NVS, восстанавливается при загрузке.
|
||||
|
||||
**Морской бой (s_b):**
|
||||
- `s_b` — основная карта, 58×42, 4 канала
|
||||
- `s_b_v1` — версия 1, 65×69, 3 канала, 4 канал шкалы (3 шкалы)
|
||||
- `s_b_v2` — версия 2, 65×69, 3 канала, 4 канал шкалы (4 шкалы)
|
||||
|
||||
**Данро (dan_ro):**
|
||||
- `dan_ro` — основная карта, 61×19, 2 стрипа (strip 0 зеркалируется в strip 1)
|
||||
- `dan_ro_v1` — версия 1, 61×19, 1 стрип (без зеркала)
|
||||
|
||||
**Резервная панель RZ (rz):**
|
||||
- `rz` — основная карта, 61×19, 2 стрипа (strip 0 зеркалируется в strip 1)
|
||||
- `rz_v1` — версия 1, 61×19, 1 стрип (без зеркала)
|
||||
|
||||
#### Специфичные программы
|
||||
|
||||
##### Отдельный раздел для призовой стойки
|
||||
|
||||
- `20` - режим простоя призовой стойки
|
||||
- `21BCF` - подсветить полку призовой стойки: `B` ячейка `1..5`, `C` цвет, `F` мигание `0/1`
|
||||
- `22` - красная мигающая тревога
|
||||
- `23` - ЛГБТ переливы на всю ленту
|
||||
- `90` - выводить внешний буфер, который заполняет рендер виртуальной панели
|
||||
|
||||
`25CCB`:
|
||||
- `CC` – подкоманда;
|
||||
- `B` – номер ячейки `1..5`, если подкоманда работает с ячейкой.
|
||||
|
||||
| Формат | Назначение |
|
||||
| :----: | :--------- |
|
||||
| `25` | Показать состояние всех ячеек |
|
||||
| `2501B` | Выдать приз из ячейки `B` |
|
||||
| `2511B` | Открыть ячейку `B` без списания приза |
|
||||
| `25991` | Включить автозапуск режима призовой стойки и перезагрузиться через 3 сек |
|
||||
| `25990` | Выключить автозапуск режима призовой стойки и перезагрузиться через 3 сек |
|
||||
|
||||
Ответы:
|
||||
- `BOX<n>_OPEN` – ячейка открыта;
|
||||
- `BOX<n>_ERROR` – ячейка не открылась или нет приза;
|
||||
- `BOX<n>_OK` – ячейка закрыта и содержит приз;
|
||||
- `BOX<n>_OK+CARD` – в ячейке есть приз и привязанная RFID-карта;
|
||||
- `BOX<n>_EMPTY` – ячейка пуста.
|
||||
|
||||
##### Отдельный раздел для Бродяги морей
|
||||
|
||||
`81XX[NN]` управляет двумя отдельными direct-объектами Бродяги морей. Эта группа похожа на direct-шкалы `8530..8534`, но не является частью команды `85` и используется отдельно от панелей `85`, `86` и `87`.
|
||||
|
||||
- `XX` - подкоманда объекта;
|
||||
- `NN` - уровень заполнения объекта;
|
||||
- короткие команды `81311..81313` - эффекты для объекта 1;
|
||||
- короткие команды `81321..81323` - эффекты для объекта 2;
|
||||
- если нужен ручной уровень `1..3`, передавайте уровень с ведущим нулем: `813101..813103` или `813201..813203`;
|
||||
- эффекты `81311..81313` и `81321..81323` ускорены примерно в 3 раза, цветовые переходы сглажены.
|
||||
|
||||
| Команда | Назначение | Максимальный уровень |
|
||||
|---|---|---|
|
||||
| `8130` | Очистить оба объекта | - |
|
||||
| `8131NN` | Объект 1. Заполнить шкалу градиентом красный -> фиолетовый -> синий | `21` |
|
||||
| `81311` | Эффект 1 объекта 1: плавный перелив синий -> фиолетовый -> фиолетово-красный | - |
|
||||
| `81312` | Эффект 2 объекта 1: плавный перелив фиолетовый -> синий -> сине-голубой | - |
|
||||
| `81313` | Эффект 3 объекта 1: мягкое мигание белым | - |
|
||||
| `8132NN` | Объект 2. Заполнить шкалу градиентом красный -> зеленый | `44` |
|
||||
| `81321` | Эффект 1 объекта 2: плавный перелив синий -> фиолетовый -> фиолетово-красный | - |
|
||||
| `81322` | Эффект 2 объекта 2: плавный перелив фиолетовый -> синий -> сине-голубой | - |
|
||||
| `81323` | Эффект 3 объекта 2: мягкое мигание белым | - |
|
||||
|
||||
Если `NN` больше длины объекта, значение ограничивается полной длиной объекта. Если `NN=0`, объект гаснет. Команды `8130`, `8131NN` и `8132NN` останавливают эффект соответствующего объекта.
|
||||
|
||||
***Примеры:***
|
||||
- `8130` - очистить оба объекта
|
||||
- `813105` - установить уровень `5` для первого объекта
|
||||
- `81311` - запустить эффект 1 первого объекта
|
||||
- `81312` - запустить эффект 2 первого объекта
|
||||
- `81313` - запустить эффект 3 первого объекта
|
||||
- `81321` - запустить эффект 1 второго объекта
|
||||
- `81322` - запустить эффект 2 второго объекта
|
||||
- `81323` - запустить эффект 3 второго объекта
|
||||
- `813101` - вручную установить уровень `1` для первого объекта, не запускать эффект
|
||||
- `813201` - вручную установить уровень `1` для второго объекта, не запускать эффект
|
||||
- `813121` - полностью зажечь первый объект
|
||||
- `813244` - полностью зажечь второй объект
|
||||
|
||||
##### Отдельный раздел для морского боя VR
|
||||
|
||||
`85XXYY`:
|
||||
- XX – команда;
|
||||
- YY – время в секундах.
|
||||
`85XX[SSS]` управляет виртуальной панелью морского боя (`s_b`). Это отдельная группа анимаций, которая работает независимо от обычных программ подсветки.
|
||||
|
||||
***Пример:*** `854505` – включить спецэффект ЗАРЯДКА на 5 секунд
|
||||
- `XX` – код управления или эффекта, две цифры после `85`;
|
||||
- `SSS` – необязательная длительность в секундах, все оставшиеся цифры после `XX`;
|
||||
- для direct-шкал `XX=30..34` хвост команды трактуется как уровень `0..22`, а не как время;
|
||||
- для тестов `XX=25` хвост команды разбирается как `TVVV`.
|
||||
|
||||
`85` – без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память).
|
||||
***Примеры разбора:***
|
||||
|
||||
###### Список эффектов
|
||||
| Команда | Как разбирается |
|
||||
|---|---|
|
||||
| `85` | Остановить анимации `s_b` |
|
||||
| `8500` | Автокарусель выключена, очистить экран, перейти в ожидание |
|
||||
| `8501` | Автокарусель включена |
|
||||
| `8510` | Горизонтальная волна с длительностью по умолчанию |
|
||||
| `851005` | Горизонтальная волна на 5 секунд |
|
||||
| `853105` | Первая direct-шкала на уровень `5` |
|
||||
| `854506` | `CHARGE` на 6 секунд |
|
||||
| `858006` | Красная тестовая заливка на 6 секунд |
|
||||
|
||||
| Команда (`XX`) | Название эффекта |
|
||||
| :-------------: | :------------------: |
|
||||
| **Базовые** | **эффекты** |
|
||||
| 00 | Горизонтальная волна |
|
||||
| 01 | Вертикальная волна |
|
||||
| 02 | Диагональная волна |
|
||||
| 03 | Плазма |
|
||||
| 04 | Ночь |
|
||||
| 05 | Радуга |
|
||||
| 06 | Огонь-Лёд |
|
||||
| 07 | Вода |
|
||||
| 08 | Звёзды |
|
||||
| 09 | Сканер |
|
||||
| 10 | Поток |
|
||||
| 11 | Буря |
|
||||
| 12 | Лёд |
|
||||
| **Специальные** | **эффекты** |
|
||||
| 40 | СТАРТ |
|
||||
| 41 | КОНЕЦ |
|
||||
| 42 | ПОПАДАНИЕ |
|
||||
| 43 | ВЫСТРЕЛ |
|
||||
| 44 | ПРОМАХ |
|
||||
| 45 | ЗАРЯДКА |
|
||||
| 46 | СОНАР |
|
||||
| 47 | КРАСНАЯ ВОЛНА |
|
||||
| 48 | ТРЕВОГА |
|
||||
| 49 | ПУЛЬСАЦИЯ |
|
||||
| 80 | красный |
|
||||
| 81 | зеленый |
|
||||
| 82 | синий |
|
||||
`85` без параметра отключает анимации `s_b`. В debug-режиме контроллер может отправить `85 - rendering OFF`.
|
||||
|
||||
Любая непустая команда `85...` запускает анимации `s_b`, если они еще не запущены. Direct-группы `813x` и `8530..8534` управляются отдельными командами и не смешиваются с обычными эффектами `85`.
|
||||
|
||||
Анимации рассчитываются как виртуальная 2D-панель. Для внешней программы важны только коды эффектов, длительность и правила возврата к предыдущему состоянию.
|
||||
|
||||
###### Управляющие коды `85`
|
||||
|
||||
| `XX` | Назначение | Длительность по умолчанию |
|
||||
|---|---|---|
|
||||
| `00` | Автокарусель выключена, очистить экран, перейти в ожидание | - |
|
||||
| `01` | Автокарусель включена | 10 сек на эффект |
|
||||
|
||||
###### Поведение после окончания эффекта
|
||||
|
||||
- Если запущен базовый эффект `10..22` и автокарусель выключена, после истечения времени экран очищается и `s_b` переходит в ожидание.
|
||||
- Если автокарусель включена, базовые эффекты переключаются по кругу `10 -> 11 -> ... -> 22 -> 10`.
|
||||
- Если запущен спецэффект `40..49` или `80..82`, после завершения контроллер возвращается к последнему базовому эффекту.
|
||||
- Если автокарусель выключена, после спецэффекта экран очищается и уходит в ожидание.
|
||||
|
||||
###### Direct-шкалы `8530..8534`
|
||||
|
||||
Эти команды управляют отдельными шкалами `s_b`. Параметр после `XX` - уровень шкалы `0..22`. Значения больше `22` ограничиваются до полной шкалы.
|
||||
|
||||
Шкалы живут независимо от эффектов и не обнуляются при `8500`, `8501`, `8510..8522`, `8540..`, перезапуске анимаций или автопереходе карусели. Обновляются командами `8531..8534`; `8530` вручную очищает шкалы.
|
||||
|
||||
| Команда | Назначение | Параметр |
|
||||
|---|---|---|
|
||||
| `8530` | Очистить все direct-шкалы | не используется |
|
||||
| `8531NN` | Шкала 1: красный -> фиолетовый -> синий | уровень `0..22` |
|
||||
| `8532NN` | Шкала 2: красный -> зеленый | уровень `0..22` |
|
||||
| `8533NN` | Шкала 3: красный -> фиолетовый -> синий | уровень `0..22` |
|
||||
| `8534NN` | Шкала 4: красный -> зеленый | уровень `0..22` |
|
||||
|
||||
***Примеры:***
|
||||
- `8530` - очистить все direct-шкалы
|
||||
- `853105` - установить уровень `5` для первой шкалы
|
||||
- `853222` - полностью зажечь вторую шкалу
|
||||
- `853300` - погасить третью шкалу
|
||||
- `853422` - полностью зажечь четвертую шкалу
|
||||
|
||||
###### Тесты морского боя VR `8525TVVV`
|
||||
|
||||
При запуске теста обычные анимации `s_b` останавливаются, и панель переходит в тестовый режим.
|
||||
|
||||
| Команда | Назначение |
|
||||
|---|---|
|
||||
| `85250` | Стоп теста, очистить тестовый режим |
|
||||
| `85251` | Общий тест подсветки VR: бегущая змейка, цвета меняются |
|
||||
| `85252NN` | Тест строки панели VR, `NN` - номер строки |
|
||||
| `85253NN` | Тест столбца панели VR, `NN` - номер столбца |
|
||||
| `85254NNN` | Тест адресного элемента `NNN` |
|
||||
|
||||
***Примеры:***
|
||||
- `85251` - змейка VR
|
||||
- `8525212` - строка `12` VR
|
||||
- `852533` - столбец `3` VR
|
||||
- `85254105` - адресный элемент `105`
|
||||
|
||||
###### Базовые эффекты `8510..8522`
|
||||
|
||||
| `XX` | Имя в коде | Визуальный смысл | Длительность по умолчанию |
|
||||
|---|---|---|---|
|
||||
| `10` | `hor wave` | Горизонтальная волна: синяя и красная волны идут навстречу | 10 сек |
|
||||
| `11` | `vert wave` | Вертикальная морская волна снизу вверх/вниз, ping-pong | 10 сек |
|
||||
| `12` | `diag wave` | Диагональная волна с темно-синим/оранжевым характером | 10 сек |
|
||||
| `13` | `plazma` | Плазма: смешение синий/красный/зеленый | 10 сек |
|
||||
| `14` | `night` | Ночной градиент: голубой, фиолетовый, розовый | 10 сек |
|
||||
| `15` | `rainbow` | Радуга, полный HSV-спектр | 10 сек |
|
||||
| `16` | `fire-ice` | Огонь-лед, красный к голубому | 10 сек |
|
||||
| `17` | `wave` | Вода, диагональная темно-синяя/голубая волна | 10 сек |
|
||||
| `18` | `stars` | Звезды, розово-зеленая диагональная динамика | 10 сек |
|
||||
| `19` | `scaner` | Зеленый сканер с затуханием | 10 сек |
|
||||
| `20` | `flame` | Поток / 2D RGB-градиент | 10 сек |
|
||||
| `21` | `shtorm` | Быстрая радужная буря | 10 сек |
|
||||
| `22` | `ice` | Лед, холодный вариант красный/голубой | 10 сек |
|
||||
|
||||
###### Спецэффекты `8540..8549`
|
||||
|
||||
Спецэффекты предназначены для событий игры. Они имеют приоритет над базовой анимацией, очищают состояние панели перед показом и после завершения возвращают систему к предыдущему базовому состоянию или в ожидание.
|
||||
|
||||
| `XX` | Имя в коде | Назначение | Длительность по умолчанию |
|
||||
|---|---|---|---|
|
||||
| `40` | `START` | Старт: волны темно-синий -> бирюзовый снизу вверх | 3 сек |
|
||||
| `41` | `FINISH` | Финиш / завершение | 3 сек |
|
||||
| `42` | `ONE_SHOT` | Попадание / торпеда | 2 сек |
|
||||
| `43` | `SHOT` | Выстрел / boom: красно-оранжево-желтые полосы от центра | 1 сек |
|
||||
| `44` | `MISS` | Промах: зелено-голубая волна снизу вверх | 1 сек |
|
||||
| `45` | `CHARGE` | Зарядка: сине-красный фон и индикатор заряда | 2 сек |
|
||||
| `46` | `SONAR` | Сонар: расширяющиеся концентрические круги | 3 сек |
|
||||
| `47` | `RED WAVE` | Красная волна сверху вниз | 3 сек |
|
||||
| `48` | `ALARM` | Тревога: мигание красным | 3 сек |
|
||||
| `49` | `PULSE` | Оранжевая пульсация | 2 сек |
|
||||
|
||||
###### Тестовые заливки `8580..8582`
|
||||
|
||||
Эти коды идут через тот же механизм спецэффектов:
|
||||
|
||||
| `XX` | Назначение | Длительность по умолчанию |
|
||||
|---|---|---|
|
||||
| `80` | Залить всю виртуальную панель красным | 3 сек |
|
||||
| `81` | Залить всю виртуальную панель зеленым | 3 сек |
|
||||
| `82` | Залить всю виртуальную панель синим | 3 сек |
|
||||
|
||||
###### Диагностика `85`
|
||||
|
||||
В debug-режиме возможны сообщения:
|
||||
|
||||
| Сообщение | Значение |
|
||||
|---|---|
|
||||
| `85 - rendering ON` | Рендер `s_b` запущен |
|
||||
| `85 - rendering OFF` | Рендер остановлен командой `85` |
|
||||
| `[S_B_] autoCarousel ON` | Автокарусель включена |
|
||||
| `[S_B_] autoCarousel OFF` | Автокарусель выключена, экран очищен |
|
||||
| `[S_B_] effect: 85XX (...)` | Запущен базовый эффект |
|
||||
| `[S_B_] gauge: 85XX, level=N` | Обновлена direct-шкала `30..34` |
|
||||
| `[S_B_] spec: XX (...)` | Запущен спецэффект |
|
||||
| `[S_B_] effect done, waiting...` | Эффект завершен, экран очищен, режим ожидания |
|
||||
|
||||
Ограничения текущего кода:
|
||||
- параметры после `85` должны быть цифрами, но строгой проверки каждого символа сейчас нет;
|
||||
- для спецэффектов в текущем коде принимается любой `XX >= 40`, но безопасно описаны только `40..49` и `80..82`;
|
||||
- полный останов анимаций выполняется пустой командой `85`.
|
||||
|
||||
##### Отдельный раздел для Данро
|
||||
|
||||
`85XXYY`:
|
||||
`86XX[SSS]`:
|
||||
- XX – команда;
|
||||
- YY – время в секундах.
|
||||
- SSS – время в секундах.
|
||||
|
||||
***Пример:*** `854505` – включить спецэффект ЗАРЯДКА на 5 секунд
|
||||
***Пример:*** `864906` – включить эффект "Падающие ряды" на 6 секунд
|
||||
|
||||
`85` – без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память).
|
||||
`86` – без параметра отключает анимации Данро.
|
||||
|
||||
Команды `86XX[SSS]` управляют только панелью Данро и не меняют direct-объекты других аппаратов.
|
||||
|
||||
###### Тесты панели Данро `8625TVVV`
|
||||
|
||||
При запуске теста обычные анимации Данро останавливаются, и панель переходит в тестовый режим.
|
||||
|
||||
| Команда | Назначение |
|
||||
| ---------- | ---------------------------------------------------------- |
|
||||
| `86250` | Стоп теста, очистить тестовый режим |
|
||||
| `86251` | Общий тест подсветки Данро: бегущая змейка, цвета меняются |
|
||||
| `86252NN` | Тест строки панели Данро, `NN` - номер строки |
|
||||
| `86253NN` | Тест столбца панели Данро, `NN` - номер столбца |
|
||||
| `86254NNN` | Тест адресного элемента `NNN` |
|
||||
|
||||
***Примеры:***
|
||||
- `86251` - змейка Данро
|
||||
- `8625212` - строка `12` Данро
|
||||
- `862533` - столбец `3` Данро
|
||||
- `86254105` - адресный элемент `105`
|
||||
|
||||
###### Список эффектов
|
||||
|
||||
| Команда (`XX`) | Название эффекта |
|
||||
| :------------: | :----------------------: |
|
||||
| 00 | Autoplay OFF, очистить экран |
|
||||
| 01 | Autoplay ON |
|
||||
| 25 | Тесты подсветки панели |
|
||||
| 40 | Горизонтальная волна |
|
||||
| 41 | Вертикальная волна |
|
||||
| 42 | Диагональная волна |
|
||||
@ -183,51 +524,68 @@ by: Паша Николаевич
|
||||
| 51 | Морская волна |
|
||||
| 52 | Диагональная волна 3 |
|
||||
|
||||
## Список команд
|
||||
##### Отдельный раздел для RZ
|
||||
|
||||
| Команда | Назначение, примеры, ответы |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 00 | Перезагрузить контроллер (RESET) |
|
||||
| 01 | Отладочная. включает-выключает подробные логи. В релизе будет отключена |
|
||||
| 02 | Подготовиться к приему файла. освободить место, отформатировать встроенную файловую, проверить хватит ли места. <br>_**Например**_, `02356987` - заявка на файл обновления размером 356987 байт.<br>Выполняется около 15 секунд, после чего весь прием данных в порту заносится в файл "update.bin".<br>Ответ при приеме файла - каждый блок (4096) - размер принятого.<br>По окончании - переход снова в режим команд (подробнее – [Обновление](#Обновление)). |
|
||||
| 03 | Показать содержимое SPIFFS |
|
||||
| 04 | Запустить ОТА обновление с ранее записанным файлом SPIFFS: `/update.bin` |
|
||||
| 05 | Показать версию прошивки (указана в дефолтах) |
|
||||
| 06 | Показать MAC-адрес (без двоеточий, как в оригинале), уникальный, можно считать серийным номером |
|
||||
| 07 | Безопасность (запрос авторизации от компа. На этот запрос создается рандомный запрос навстречу. См. [[#Алгоритм авторизации]]) |
|
||||
| 08 | Безопасность (прием ответа от компьютера на запрос `07`. См. [[#Алгоритм авторизации]]) |
|
||||
| 09 | Сброс EEPROM (параметром - MAC без двоеточий, иначе не сбросится) |
|
||||
| 10 | Подтверждение принятия сигнала COIN, иначе этот сигнал будет посылаться снова с периодичностью `200 мс` (сколько приняли койнов, столько и подтверждений быть должно) |
|
||||
| 20X | Установить состояние выходов - всего выходов 8, давать их двоичное состояние.<br>_**Например**_, `203` - вывести 1 и 2 выходы по единичке, остальные - нолики. Соответственно, выход на разъеме будет "прижат" к земле. |
|
||||
| 21XY | Установить состояние выхода номер Х в -> Y \[0,1,2(инверсия)\].<br>_**Примеры:**_<br>`2131` - Включить 3 по индексу выход<br>`2170` - Выключить 7 по индексу выход |
|
||||
| 30X | Хоппер 1.<br>`301` - Включить мотор хоппера<br>`300` - Выключить мотор хоппера<br>`309` - Выдать приз (в авторежиме[^1]) |
|
||||
| 40X | Хоппер 2.<br>Аналогично |
|
||||
| 8XY | Выбор программы адресных диодов Y по каналу X (См. [[#Программы адресных диодов]]) |
|
||||
| 85XXYY | См. [[#Специфичные программы]] |
|
||||
| 85 | Без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память) |
|
||||
| 89\[X\] | Переключить цветовое чередование.<br>***Примеры:***<br>- `89` или `890` – RGB<br>- `891` – RBG<br>- `892` – BRG<br>- `893` – BGR<br>- `894` - GRB<br>- `895` - GBR |
|
||||
| 90 | Очистить очередь команд |
|
||||
| 91 | Запрос состояния цифровых входов, их всего 24(+2 входа хоппера), ответ в двоичном коде.<br>Для уменьшения объема передаваемой информации, преобразуется в десятичное число<br>_**Примеры:**_<br>`INPUT67108863` (`11111111111111111111111111`) - все кнопки не нажаты<br>`INPUT67108861` (`11111111111111111111111101`) - нажата вторая кнопка |
|
||||
| 91X | _Дополнительно_: задать время антидребезга платы (`10..100`; по умолчанию установлено на 50 мс)<br>_**Например**_, 9110 - Установить антидребезг на 10 мс |
|
||||
| 92 | Запрос значения аналоговых входов **(отключает автовывод)** - ответ в формате `ADC0,0,0,0`.<br>Всего 4 аналоговых входа |
|
||||
| 92X | Периодический автовывод значений аналоговых входов.<br>_**Например**_, `92100` - каждые 100 милисекунд выдавать значения ADC (`50..5000`) |
|
||||
| 93 | Запрос `global_summa` (число коинов, нестираемо) |
|
||||
| 96 | Cохранение в памяти платы пользовательского значения.<br>***Например,*** «роли» контроллера (левый-правый в морском бое, или шкаф…)<br>***Пример команды:*** `96right` |
|
||||
| 97 | Вывод пользовательского значения<br>***Формат вывода:*** `ROLE : <значение>` (до 20 символов) |
|
||||
| 98 | Запрос загруженности ядер, оперативки, свободного места в памяти |
|
||||
| 99 | Запрос статуса |
|
||||
`87XX[SSS]` - резервная виртуальная панель. Формат и эффекты совпадают с `86`.
|
||||
|
||||
[^1]: Авторежим: если датчик в закрытом состоянии - на полсекунды включить мотор, ждать сработки датчика либо таймаута. Если таймаут - выдать сообщение "HOPPER1 ERROR" и наглухо повесить процесс хоппера до перезагрузки. Иначе - сообщение "HOPPER1 OK"
|
||||
Команды `87XX[SSS]` управляют только резервной панелью RZ и не меняют direct-объекты других аппаратов.
|
||||
|
||||
###### Тесты панели RZ `8725TVVV`
|
||||
|
||||
При запуске теста обычные анимации RZ останавливаются, и панель переходит в тестовый режим.
|
||||
|
||||
| Команда | Назначение |
|
||||
|---|---|
|
||||
| `87250` | Стоп теста, очистить тестовый режим |
|
||||
| `87251` | Общий тест подсветки RZ: бегущая змейка, цвета меняются |
|
||||
| `87252NN` | Тест строки панели RZ, `NN` - номер строки |
|
||||
| `87253NN` | Тест столбца панели RZ, `NN` - номер столбца |
|
||||
| `87254NNN` | Тест адресного элемента `NNN` |
|
||||
|
||||
***Примеры:***
|
||||
- `8701` - включить автоперебор резервной панели
|
||||
- `8743` - rainbow с длительностью по умолчанию
|
||||
- `872533` - тест столбца `3`
|
||||
- `87254105` - адресный элемент `105`
|
||||
- `87` - остановить анимации
|
||||
|
||||
##### Дополнительные цифровые GPIO
|
||||
|
||||
`50CCP`:
|
||||
- `CC` – подкоманда;
|
||||
- `P` – числовой параметр.
|
||||
|
||||
| Формат | Назначение |
|
||||
| :----: | :--------- |
|
||||
| `50` | Показать режимы и уровни GPIO25/GPIO26 |
|
||||
| `5001P` | Настроить режимы: если `P > 9`, GPIO25 выход; если младший бит `P` равен `1`, GPIO26 выход |
|
||||
| `5005P` | Показать краткое состояние: `ADD:..` |
|
||||
| `5010P` | Установить уровни выходов: GPIO25 = `P > 9`, GPIO26 = младший бит `P` |
|
||||
|
||||
# Обновление прошивки платы
|
||||
|
||||
## Православный (новый)
|
||||
|
||||
Зайти в [[Новое приложение для работы с платой|програму для платы]], и [[Новое приложение для работы с платой#Обновление прошивки|обновить прошивку]] через него.
|
||||
|
||||
Для этого надо:
|
||||
1. Заполучить новую прошивку
|
||||
2. Подключить плату
|
||||
3. Открыть приложение
|
||||
4. Подключиться к плате через приложение
|
||||
5. Обновить прошивку через специальный пункт "Обновление прошивки"
|
||||
|
||||
## Пендосский (старый)
|
||||
|
||||
Файл обновления принимается командой `02<size>`. После полного приема контроллер отвечает текущим количеством записанных байт и итоговым `file size=<size> OK`, затем обновление запускается командой `04`.
|
||||
|
||||
# Обновление
|
||||
|
||||
Для обновления платы на новую версию прошивки надо:
|
||||
1. Волшебным образом заполучить флешку с новой прошивкой
|
||||
2. Проверить, что прошивка на плате точно старше при помощи команды `05`
|
||||
3. Выбрать файл новой прошивки с флешки
|
||||
4. Вполнить появившуюся команду
|
||||
5. После успешного выполнения команды, проверить состояние SPIFFS
|
||||
4. Выполнить появившуюся команду
|
||||
5. После успешного выполнения команды, проверить состояние файла обновления
|
||||
6. При наличии выделенного места, обновить прошивку при помощи команды `04`, дождаться завершения обновления
|
||||
7. Проверить, что прошивка на плату встала при помощи команды `05`
|
||||
8. ...
|
||||
9. PROFIT!
|
||||
9. PROFIT!
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user