plate-config-app/app/widgets/sections/firmware_section.py
Danil Iarosh 9fd10a3acd v1.3.0: simple mode backlight and UI polish
- обновлён простой режим: тест цвета и проверка подсветки кнопок объединены в раздел «Тест подсветки»

- добавлена проверка подсветки кнопок через 8-й силовой выход с автоматическим коротким тестом

- упрощены описания пользовательских проверок и убраны лишние технические подробности из простого режима

- изменена раскладка теста цвета, увеличена длительность шага до 1 секунды и убраны описания из PRO-блока

- добавлена защита выпадающих списков от случайного изменения колесом мыши

- улучшены немодальные PRO-диалоги, настройки соединения и обработка событий COM-порта

- обновлены визуальные детали приложения, пользовательские ресурсы, инструкции AGENTS и Release Notes 1.3.0
2026-05-26 12:59:21 +03:00

222 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Встраиваемая секция «Обновление прошивки» — загрузка и OTA.
Повторяет функциональность FirmwareDialog, но как обычный QWidget.
"""
from __future__ import annotations
import os
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import (
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QProgressBar,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ...services.com_service import ComConnection
from ...ui.icons import get_icon_color, icon
# Переиспользуем воркер из диалога
from ..dialogs.firmware_dialog import _UploadWorker
class FirmwareSection(QWidget):
"""Inline-секция обновления прошивки (для базового режима)."""
upload_started = Signal()
upload_finished = Signal()
def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._conn = connection
self._firmware_path: str | None = None
self._upload_thread: QThread | None = None
self._worker: _UploadWorker | None = None
self._need_ota: bool = False
self._setup_ui()
# ── UI ────────────────────────────────────────────────────────────
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(10)
# ── Обновление прошивки ──
fw_group = QGroupBox("Обновление прошивки")
fw_layout = QVBoxLayout(fw_group)
desc = QLabel(
"Выберите файл прошивки и запустите обновление. "
"Дождитесь завершения прогресса и сообщения о перезагрузке платы. "
"Во время обновления остальные проверки временно недоступны."
)
desc.setWordWrap(True)
desc.setObjectName("HelpText")
fw_layout.addWidget(desc)
file_layout = QHBoxLayout()
self._file_label = QLabel("Файл не выбран")
self._file_label.setWordWrap(True)
file_layout.addWidget(self._file_label, 1)
self._pick_btn = QPushButton("Выбрать .bin")
self._pick_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color()))
self._pick_btn.clicked.connect(self._pick_file)
file_layout.addWidget(self._pick_btn)
fw_layout.addLayout(file_layout)
self._progress = QProgressBar()
self._progress.setValue(0)
fw_layout.addWidget(self._progress)
self._update_btn = QPushButton("Обновить прошивку")
self._update_btn.setProperty("role", "primary")
self._update_btn.setIcon(icon("fa5s.upload", color="#ecfdf5"))
self._update_btn.clicked.connect(self._start_update)
fw_layout.addWidget(self._update_btn)
root.addWidget(fw_group)
# ── Лог ──
self._log = QTextEdit()
self._log.setReadOnly(True)
self._log.setMaximumHeight(160)
root.addWidget(self._log)
# ── Public ────────────────────────────────────────────────────────
def refresh_icons(self) -> None:
clr = get_icon_color()
self._pick_btn.setIcon(icon("fa5s.folder-open", color=clr))
def cancel_upload(self) -> None:
"""Прервать загрузку (если идёт) — вызывается при скрытии секции."""
if self._upload_thread is not None and self._upload_thread.isRunning():
self._upload_thread.quit()
self._upload_thread.wait(3000)
# ── Загрузка файла ────────────────────────────────────────────────
def _pick_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Выбрать файл прошивки", "", "BIN Files (*.bin)")
if path:
self._firmware_path = path
size = os.path.getsize(path)
self._file_label.setText(f"{os.path.basename(path)} ({size:,} байт)")
self._progress.setValue(0)
def _start_update(self) -> None:
"""Загрузить файл на плату (02), а затем автоматически прошить (04)."""
if not self._firmware_path:
QMessageBox.warning(self, "Ошибка", "Сначала выберите файл прошивки.")
return
if not self._conn.is_connected:
QMessageBox.warning(self, "Ошибка", "Порт не подключён.")
return
self._update_btn.setEnabled(False)
self._pick_btn.setEnabled(False)
self._progress.setValue(0)
self._log.append("Запуск загрузки…")
self.upload_started.emit()
# Очистить serial-буфер от остатков предыдущего опроса
try:
ser = self._conn.serial
if ser is not None and ser.is_open:
ser.reset_input_buffer()
except Exception:
pass
self._worker = _UploadWorker(self._conn, self._firmware_path)
self._upload_thread = QThread()
self._worker.moveToThread(self._upload_thread)
self._worker.progress.connect(self._on_upload_progress)
self._worker.log_message.connect(self._log.append)
self._worker.finished.connect(self._on_upload_done)
self._worker.finished.connect(self._upload_thread.quit)
self._upload_thread.finished.connect(self._cleanup_upload)
self._upload_thread.started.connect(self._worker.run)
self._upload_thread.start()
def _cleanup_upload(self) -> None:
if self._worker is not None:
self._worker.deleteLater()
self._worker = None
if self._upload_thread is not None:
self._upload_thread.deleteLater()
self._upload_thread = None
# Worker thread полностью завершён, finally выполнен, lock свободен.
# Теперь безопасно запускать OTA.
if self._need_ota:
self._need_ota = False
self._flash_ota()
def _on_upload_progress(self, percent: int) -> None:
self._progress.setValue(percent)
def _on_upload_done(self, success: bool) -> None:
if success:
self._log.append("✓ Загрузка завершена. Запуск OTA (04)…")
# Не вызываем _flash_ota сразу: worker thread ещё в finally,
# io_lock может быть занят. Отложим до _cleanup_upload,
# который сработает после реального завершения потока.
self._need_ota = True
else:
self._need_ota = False
self._log.append("✗ Загрузка не удалась.")
self._update_btn.setEnabled(True)
self._pick_btn.setEnabled(True)
self.upload_finished.emit()
def _flash_ota(self) -> None:
if not self._conn.is_connected:
self._log.append("✗ Порт не подключён.")
self._update_btn.setEnabled(True)
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}")
self._update_btn.setEnabled(True)
self._pick_btn.setEnabled(True)
self.upload_finished.emit()