# firmware_test: TUI app major refactoring

This commit is contained in:
Dmitry Akimov 2026-06-30 15:09:47 +03:00
parent 55cd9e8273
commit 6e42970fef
27 changed files with 1893 additions and 333 deletions

View file

@ -16,10 +16,6 @@ FLASHLOADER_PID=0073
SERVICE_CDC_VID=1996 SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad SERVICE_CDC_PID=00ad
# Тип сборки firmware_test для TUI (Debug | Release)
FIRMWARE_BUILD_TYPE=Debug
# --- Paths --- # --- Paths ---
# BUILD_DIR и TOOLS_DIR задаются абсолютно в корневом justfile # BUILD_DIR и TOOLS_DIR задаются абсолютно в корневом justfile
# через justfile_directory(), здесь можно переопределить если нужно # через justfile_directory(), здесь можно переопределить если нужно
@ -38,9 +34,10 @@ GDB_EXECUTABLE=arm-none-eabi-gdb
PYOCD_TARGET=mimxrt1050_quadspi PYOCD_TARGET=mimxrt1050_quadspi
PYOCD_FREQUENCY=4000000 PYOCD_FREQUENCY=4000000
# FCB-бинарник для flash_swd.py (Flash Configuration Block, W25Q128 Quad SPI) # FCB-бинарник для flash_swd.py (W25Q128 Quad SPI)
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
# --- HIL (аппаратный стенд) --- # --- HIL (аппаратный стенд) ---
HIL_PYOCD_FREQUENCY=1000000 HIL_PYOCD_FREQUENCY=1000000
HIL_BUILD_DIR=build/target-debug HIL_BUILD_DIR=build/target-debug
@ -62,3 +59,6 @@ HIL_M5_TIMEOUT=3.0
HIL_USB_CDC_PORT=/dev/cu.usbmodem11301 HIL_USB_CDC_PORT=/dev/cu.usbmodem11301
HIL_USB_CDC_BAUD=115200 HIL_USB_CDC_BAUD=115200
HIL_USB_CDC_TIMEOUT=5.0 HIL_USB_CDC_TIMEOUT=5.0
# Тип сборки firmware_test для TUI (Debug | Release)
FIRMWARE_BUILD_TYPE=Release

View file

@ -109,10 +109,10 @@ set(CMAKE_ASM_FLAGS_DEBUG
CACHE INTERNAL "") CACHE INTERNAL "")
set(CMAKE_C_FLAGS_RELEASE set(CMAKE_C_FLAGS_RELEASE
"-O3 -DNDEBUG" "-O1 -DNDEBUG"
CACHE INTERNAL "") CACHE INTERNAL "")
set(CMAKE_CXX_FLAGS_RELEASE set(CMAKE_CXX_FLAGS_RELEASE
"-O3 -DNDEBUG" "-O1 -DNDEBUG"
CACHE INTERNAL "") CACHE INTERNAL "")
set(CMAKE_ASM_FLAGS_RELEASE set(CMAKE_ASM_FLAGS_RELEASE
"" ""

View file

@ -84,7 +84,7 @@ target_link_options(
-Wl,-Map=${CMAKE_BINARY_DIR}/firmware_test.map -Wl,-Map=${CMAKE_BINARY_DIR}/firmware_test.map
-Wl,--defsym=__stack_size__=0x2000 -Wl,--defsym=__stack_size__=0x2000
-Wl,--defsym=__heap_size__=0x2000 -Wl,--defsym=__heap_size__=0x2000
-T${PROJECT_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_flexspi_nor_sdram.ld) -T${CMAKE_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_flexspi_nor_sdram.ld)
set_target_properties(${TARGET_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY set_target_properties(${TARGET_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}) ${CMAKE_BINARY_DIR})

View file

@ -165,7 +165,7 @@ static test_result_t buttons_test_run(void)
/** @brief Дескриптор тест-модуля кнопок для реестра test_runner. */ /** @brief Дескриптор тест-модуля кнопок для реестра test_runner. */
const test_module_t K_TEST_BUTTONS = { const test_module_t K_TEST_BUTTONS = {
.id = "buttons", .id = "buttons",
.name = "Test Buttons", .name = "Buttons",
.critical = false, .critical = false,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -197,7 +197,7 @@ static test_result_t can_run(void)
/** @brief Дескриптор для регистрации в test_runner. */ /** @brief Дескриптор для регистрации в test_runner. */
const test_module_t K_TEST_CAN = { const test_module_t K_TEST_CAN = {
.id = "can", .id = "can",
.name = "CAN loopback", .name = "CAN Network",
.critical = false, .critical = false,
.requires_hil = true, .requires_hil = true,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -308,7 +308,7 @@ static void display_test_deinit(void)
const test_module_t K_TEST_DISPLAY = { const test_module_t K_TEST_DISPLAY = {
.id = "display", .id = "display",
.name = "TFT Display RGB888", .name = "TFT Display",
.critical = false, .critical = false,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -220,7 +220,7 @@ static void test_mqs_deinit(void)
const test_module_t K_TEST_MQS = { const test_module_t K_TEST_MQS = {
.id = "mqs", .id = "mqs",
.name = "MQS Audio Out", .name = "Audio Out",
.critical = false, .critical = false,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -380,7 +380,7 @@ static void qspi_test_deinit(void)
const test_module_t K_TEST_QSPI = { const test_module_t K_TEST_QSPI = {
.id = "qspi", .id = "qspi",
.name = "QSPI Flash W25Qxx", .name = "NOR Flash W25Qxx",
.critical = true, .critical = true,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -367,7 +367,7 @@ static void sdram_test_deinit(void)
const test_module_t K_TEST_SDRAM = { const test_module_t K_TEST_SDRAM = {
.id = "sdram", .id = "sdram",
.name = "SDRAM 32 MB", .name = "SDRAM MT48LC16x",
.critical = true, .critical = true,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = NULL, .pre_confirm_prompt = NULL,

View file

@ -343,7 +343,7 @@ static void usd_deinit(void)
const test_module_t K_TEST_USD = { const test_module_t K_TEST_USD = {
.id = "usd", .id = "usd",
.name = "microSD (SDIO)", .name = "microSD Card",
.critical = false, .critical = false,
.requires_hil = false, .requires_hil = false,
.pre_confirm_prompt = "Insert microSD card and press OK", .pre_confirm_prompt = "Insert microSD card and press OK",

View file

@ -17,8 +17,8 @@ from textual.binding import Binding
from .firmware_client import FirmwareClient from .firmware_client import FirmwareClient
from .m5_client import M5Client from .m5_client import M5Client
from .models import AppMode from .models import AppMode, FlashTarget
from .screens import DiagScreen, FlashScreen, WaitingScreen from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,11 +30,12 @@ class ServiceApp(App):
"""Корневое приложение service-tui.""" """Корневое приложение service-tui."""
TITLE = "TFT Board Service Tool" TITLE = "TFT Board Service Tool"
CSS_PATH = "app.tcss" # относительно app/app.py → app/app.tcss
BINDINGS = [ BINDINGS = [
Binding("ctrl+c", "quit", "Выход", show=True), Binding("ctrl+c", "quit", "Выход", show=True),
Binding("ctrl+q", "quit", "Выход"), Binding("ctrl+q", "quit", "Выход"),
] ]
CSS_PATH = "app.tcss"
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -54,8 +55,20 @@ class ServiceApp(App):
self._connect_and_diagnose() self._connect_and_diagnose()
@on(FlashScreen.FlashDone) @on(FlashScreen.FlashDone)
def _on_flash_done(self, _event: FlashScreen.FlashDone) -> None: def _on_flash_done(self, event: FlashScreen.FlashDone) -> None:
"""После прошивки — вернуться на WaitingScreen.""" """
После прошивки:
- firmware_test + успех PostFlashScreen (промпт смены BootMode)
- всё остальное (production/custom, либо ошибка) сразу WaitingScreen
"""
if event.success and event.target == FlashTarget.FIRMWARE_TEST:
self.switch_screen(PostFlashScreen())
else:
self.switch_screen(WaitingScreen())
@on(PostFlashScreen.Done)
def _on_post_flash_done(self) -> None:
"""Оператор подтвердил смену BootMode или истёк таймаут."""
self.switch_screen(WaitingScreen()) self.switch_screen(WaitingScreen())
@on(DiagScreen.DiagDone) @on(DiagScreen.DiagDone)
@ -76,7 +89,16 @@ class ServiceApp(App):
return return
self._m5 = await M5Client.auto_connect() self._m5 = await M5Client.auto_connect()
self.switch_screen(DiagScreen(firmware=self._fw, m5=self._m5))
fw_version = ""
try:
fw_version = await self._fw.get_version()
except Exception as exc:
logger.warning("get_version failed: %s", exc)
self.switch_screen(
DiagScreen(firmware=self._fw, m5=self._m5, fw_version=fw_version)
)
def _disconnect(self) -> None: def _disconnect(self) -> None:
if self._fw is not None: if self._fw is not None:

View file

@ -1,13 +1,31 @@
/* ── WaitingScreen ───────────────────────────────────────────────────────── */ /* ═══════════════════════════════════════════════════════════
service-tui — единый файл стилей
Путь резолвится относительно app/app.py → app/app.tcss
═══════════════════════════════════════════════════════════ */
WaitingScreen { /* ── Общая рамка приложения (AppFrame) ──────────────────── */
/* Все три экрана центрируют один AppFrame фиксированного */
/* размера — единообразный визуальный каркас независимо от */
/* размера окна терминала (как в ratatui-приложениях). */
Screen {
align: center middle; align: center middle;
layout: vertical;
} }
WaitingScreen > Static { AppFrame {
content-align: center middle;
width: 100%; width: 100%;
height: 100%;
max-width: 160;
max-height: 50;
border: heavy $primary;
background: $surface;
padding: 1 2;
}
/* ── WaitingScreen ──────────────────────────────────────── */
#waiting-frame {
align: center middle;
} }
#waiting-logo { #waiting-logo {
@ -16,9 +34,8 @@ WaitingScreen > Static {
border: round $primary; border: round $primary;
padding: 1 2; padding: 1 2;
content-align: center middle; content-align: center middle;
text-style: bold;
color: $primary; color: $primary;
margin-bottom: 0; text-style: bold;
} }
#waiting-hint { #waiting-hint {
@ -33,11 +50,7 @@ WaitingScreen > Static {
content-align: center middle; content-align: center middle;
} }
/* ── FlashScreen ─────────────────────────────────────────────────────────── */ /* ── FlashScreen ────────────────────────────────────────── */
FlashScreen {
padding: 1 2;
}
#flash-title { #flash-title {
text-style: bold; text-style: bold;
@ -52,7 +65,6 @@ FlashScreen {
height: auto; height: auto;
} }
#flash-custom-path { #flash-custom-path {
margin-top: 1; margin-top: 1;
height: auto; height: auto;
@ -72,12 +84,14 @@ FlashScreen {
align: left middle; align: left middle;
} }
#flash-btn-flash { #flash-btn-flash,
#flash-btn-erase {
margin-right: 2; margin-right: 2;
} }
#flash-btn-flash:disabled, #flash-btn-flash:disabled,
#flash-btn-erase:disabled { #flash-btn-erase:disabled,
#flash-btn-quit:disabled {
background: $panel; background: $panel;
color: $text-muted; color: $text-muted;
} }
@ -86,8 +100,8 @@ FlashScreen {
margin-top: 1; margin-top: 1;
} }
#flash-progress-label { #flash-progress-bar.hidden {
color: $text-muted; display: none;
} }
#flash-log { #flash-log {
@ -98,15 +112,51 @@ FlashScreen {
overflow-y: auto; overflow-y: auto;
} }
/* ── DiagScreen ──────────────────────────────────────────────────────────── */ /* ── PostFlashScreen ────────────────────────────────────── */
DiagScreen { #post-flash-frame {
layout: grid; align: center middle;
grid-size: 1; }
grid-rows: 3 1fr 1 1 3 auto;
#post-flash-title {
text-style: bold;
color: $success;
content-align: center middle;
margin-bottom: 2;
}
#post-flash-instruction {
border: round $warning;
padding: 1 3;
color: $text;
text-style: bold;
content-align: center middle;
width: auto;
}
#post-flash-countdown {
margin-top: 2;
color: $text-muted;
content-align: center middle;
}
#post-flash-btn-row {
margin-top: 2;
height: auto;
align: center middle;
}
#post-flash-btn-ok {
margin-right: 2;
}
/* ── DiagScreen ─────────────────────────────────────────── */
#diag-frame {
layout: vertical;
padding: 0;
} }
/* Шапка */
#diag-header { #diag-header {
height: 3; height: 3;
background: $panel; background: $panel;
@ -115,16 +165,21 @@ DiagScreen {
} }
#diag-header-fw { #diag-header-fw {
width: auto;
min-width: 14;
color: $text-muted; color: $text-muted;
margin-right: 3; margin-right: 3;
} }
#diag-header-uid { #diag-header-uid {
width: auto;
min-width: 28;
color: $text-muted; color: $text-muted;
margin-right: 3; margin-right: 3;
} }
#diag-header-m5 { #diag-header-m5 {
width: auto;
color: $success; color: $success;
} }
@ -132,24 +187,30 @@ DiagScreen {
color: $text-muted; color: $text-muted;
} }
/* Рабочая зона */
#diag-main { #diag-main {
height: 1fr; height: 1fr;
min-height: 10;
}
#diag-progress-row {
height: auto;
padding: 0 2;
}
#diag-progress-row.hidden {
display: none;
} }
/* Прогресс */
#diag-progress-bar { #diag-progress-bar {
height: 1; height: 1;
margin: 0 2; margin-top: 1;
} }
#diag-progress-label { #diag-progress-label {
height: 1; height: 1;
color: $text-muted; color: $text-muted;
padding: 0 2;
} }
/* Кнопки запуска */
#diag-btn-row { #diag-btn-row {
height: 3; height: 3;
align: left middle; align: left middle;
@ -157,7 +218,8 @@ DiagScreen {
background: $panel; background: $panel;
} }
#diag-btn-run-selected { #diag-btn-run-selected,
#diag-btn-run-all {
margin-right: 2; margin-right: 2;
} }
@ -167,7 +229,7 @@ DiagScreen {
color: $text-muted; color: $text-muted;
} }
/* ── TestListPanel ───────────────────────────────────────────────────────── */ /* ── TestListPanel ──────────────────────────────────────── */
TestListPanel { TestListPanel {
width: 38; width: 38;
@ -182,9 +244,19 @@ TestListPanel .section-title {
padding: 0 0 1 0; padding: 0 0 1 0;
} }
.test-row { #test-list-select-row {
align: left middle;
height: auto; height: auto;
margin-bottom: 1;
}
#test-list-select-row Button {
margin-right: 1;
min-width: 12;
}
.test-row {
height: auto;
align: left middle;
} }
.test-row-hil-badge { .test-row-hil-badge {
@ -196,10 +268,10 @@ TestListPanel .section-title {
color: $text-muted; color: $text-muted;
} }
/* ── ResultsPanel ────────────────────────────────────────────────────────── */ /* ── ResultsPanel ───────────────────────────────────────── */
ResultsPanel { ResultsPanel {
width: 1fr; width: 70;
padding: 1 2; padding: 1 2;
overflow-y: auto; overflow-y: auto;
} }
@ -210,45 +282,26 @@ ResultsPanel .section-title {
padding: 0 0 1 0; padding: 0 0 1 0;
} }
.result-row { #results-empty {
height: 1; height: 1fr;
align: left middle; width: 100%;
} }
.result-id { #results-empty-text {
color: $text-muted; color: $text-muted;
width: 12; text-align: center;
} }
.result-status-pass { #results-table {
color: $success; height: auto;
width: 8; max-height: 100%;
text-style: bold;
} }
.result-status-fail { #results-table.hidden {
color: $error; display: none;
width: 8;
text-style: bold;
} }
.result-status-running { /* ── ConfirmPanel ───────────────────────────────────────── */
color: $warning;
width: 8;
}
.result-status-skip {
color: $text-muted;
width: 8;
}
.result-detail {
color: $text-muted;
width: 1fr;
overflow: hidden;
}
/* ── ConfirmPanel ────────────────────────────────────────────────────────── */
ConfirmPanel { ConfirmPanel {
height: auto; height: auto;

View file

@ -12,9 +12,10 @@ firmware_client.py — async CDC-клиент firmware_test.
FirmwareClient.run_selected() запустить тесты, вернуть AsyncGenerator событий FirmwareClient.run_selected() запустить тесты, вернуть AsyncGenerator событий
FirmwareClient.send_confirm() отправить confirm FirmwareClient.send_confirm() отправить confirm
FirmwareClient.get_uid() get_uid str (hex UID) FirmwareClient.get_uid() get_uid str (hex UID)
FirmwareClient.get_version() get_version str (X.Y.Z)
FirmwareClient.send_cmd_raw() отправить произвольную JSON-команду FirmwareClient.send_cmd_raw() отправить произвольную JSON-команду
Все blocking-операции с pyserial выполняются в asyncio.get_event_loop().run_in_executor() Все blocking-операции с pyserial выполняются в run_in_executor()
чтобы не блокировать event loop Textual. чтобы не блокировать event loop Textual.
""" """
@ -28,7 +29,7 @@ from typing import AsyncGenerator, Optional
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
from .models import TestInfo, TestStatus from .models import TestInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,14 +61,6 @@ def _parse_event(line: str) -> Optional[dict]:
return None return None
def _parse_test_status(s: str) -> TestStatus:
return {
"pass": TestStatus.PASS,
"fail": TestStatus.FAIL,
"skip": TestStatus.SKIP,
}.get(s, TestStatus.FAIL)
class FirmwareClient: class FirmwareClient:
""" """
Async-клиент для общения с firmware_test по USB CDC ACM. Async-клиент для общения с firmware_test по USB CDC ACM.
@ -194,7 +187,7 @@ class FirmwareClient:
return False return False
async def get_version(self) -> str: async def get_version(self) -> str:
"""Запросить версию firmware_test. Вернуть строку X.Y.Z или ''.""" """Запросить версию firmware_test. Вернуть строку 'X.Y.Z' или ''."""
await self._send({"type": "cmd", "cmd": "get_version"}) await self._send({"type": "cmd", "cmd": "get_version"})
async for event in self._recv_until({"version_response"}, timeout_s=3.0): async for event in self._recv_until({"version_response"}, timeout_s=3.0):
if event.get("type") == "version_response": if event.get("type") == "version_response":

View file

@ -8,6 +8,7 @@ flasher.py — обёртка над tools/host/flash_usb.py для TUI.
Публичный API: Публичный API:
Flasher.flash(target, bin_path, progress_cb) async, прогресс через callback Flasher.flash(target, bin_path, progress_cb) async, прогресс через callback
Flasher.erase_chip(progress_cb) async chip erase
Flasher.detect_sdp() проверить наличие BootROM SDP Flasher.detect_sdp() проверить наличие BootROM SDP
Flasher.detect_cdc() проверить наличие CDC firmware_test Flasher.detect_cdc() проверить наличие CDC firmware_test
""" """
@ -31,17 +32,18 @@ _BOOTROM_PID = int(os.environ.get("BOOTROM_PID", "0x0130"), 16)
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16) _CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16)
_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16) _CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16)
# Тип сборки firmware_test для прошивки (Debug | Release).
# Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug.
_FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug")
# Путь до flash_usb.py относительно корня репозитория # Путь до flash_usb.py относительно корня репозитория
_FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py" _FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py"
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent _HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent
# Паттерны stdout flash_usb.py для извлечения прогресса # Паттерны stdout flash_usb.py для извлечения прогресса
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%") _RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
_RE_PHASE = re.compile(r"(sdphost|blhost|Writing|Erasing|Verifying)", re.IGNORECASE) _RE_PHASE = re.compile(r"(sdphost|blhost|Writing|Erasing|Verifying)", re.IGNORECASE)
_FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug")
ProgressCallback = Callable[[FlashProgress], Awaitable[None]] ProgressCallback = Callable[[FlashProgress], Awaitable[None]]
@ -50,10 +52,10 @@ def _detect_usb(vid: int, pid: int) -> bool:
Проверить наличие USB-устройства по VID/PID (синхронно). Проверить наличие USB-устройства по VID/PID (синхронно).
Два метода детекта: Два метода детекта:
1. pyusb (usb.core) видит все USB-устройства включая SDP bulk/HID 1. pyusb (usb.core) видит все USB-устройства включая SDP bulk/HID.
(на macOS SDP не создаёт serial-порт и невидим через list_ports). На macOS SDP-устройство (1FC9:0130) не создаёт serial-порт
2. serial.tools.list_ports fallback для CDC ACM устройств и невидимо через serial.tools.list_ports.
если pyusb недоступен. 2. serial.tools.list_ports fallback для CDC ACM устройств.
""" """
# Метод 1: pyusb — работает для SDP и CDC # Метод 1: pyusb — работает для SDP и CDC
try: try:
@ -140,9 +142,9 @@ class Flasher:
""" """
Запустить прошивку через flash_usb.py. Запустить прошивку через flash_usb.py.
:param target: Что прошиваем (firmware_test или production). :param target: Что прошиваем (firmware_test, production или custom).
:param progress_cb: Async callback с FlashProgress (может быть None). :param progress_cb: Async callback с FlashProgress (может быть None).
:param bin_path: Путь к бинарю (опционально, если overriding default). :param bin_path: Путь к бинарю (обязателен для CUSTOM).
:return: True при успехе. :return: True при успехе.
""" """
if target == FlashTarget.FIRMWARE_TEST: if target == FlashTarget.FIRMWARE_TEST:
@ -160,12 +162,12 @@ class Flasher:
return await self._run_flash_bin(bin_path, progress_cb) return await self._run_flash_bin(bin_path, progress_cb)
return False return False
async def _run_flash( # прошивка стандартного firmware по имени async def _run_flash(
self, self,
firmware: str, firmware: str,
progress_cb: Optional[ProgressCallback], progress_cb: Optional[ProgressCallback],
) -> bool: ) -> bool:
"""Запустить flash_usb.py для одного бинаря.""" """Запустить flash_usb.py для одного бинаря (стандартный firmware)."""
cmd = [ cmd = [
"uv", "uv",
"run", "run",
@ -180,11 +182,12 @@ class Flasher:
] ]
return await self._run_cmd(cmd, firmware, progress_cb) return await self._run_cmd(cmd, firmware, progress_cb)
async def _run_flash_bin( # прошивка произвольного бинаря async def _run_flash_bin(
self, self,
bin_path: Path, bin_path: Path,
progress_cb: Optional[ProgressCallback], progress_cb: Optional[ProgressCallback],
) -> bool: ) -> bool:
"""Запустить flash_usb.py для произвольного бинаря."""
cmd = [ cmd = [
"uv", "uv",
"run", "run",
@ -197,12 +200,13 @@ class Flasher:
] ]
return await self._run_cmd(cmd, bin_path.stem, progress_cb) return await self._run_cmd(cmd, bin_path.stem, progress_cb)
async def _run_cmd( # общий subprocess runner async def _run_cmd(
self, self,
cmd: list[str], cmd: list[str],
label: str, label: str,
progress_cb: Optional[ProgressCallback], progress_cb: Optional[ProgressCallback],
) -> bool: ) -> bool:
"""Общий subprocess runner для всех операций flash_usb.py."""
logger.info("Running: %s", " ".join(cmd)) logger.info("Running: %s", " ".join(cmd))
try: try:
self._proc = await asyncio.create_subprocess_exec( self._proc = await asyncio.create_subprocess_exec(

View file

@ -4,7 +4,7 @@ orchestrator.py — оркестратор confirm_request для TUI.
Получает события от FirmwareClient, принимает решение кто должен Получает события от FirmwareClient, принимает решение кто должен
ответить на confirm_request, и отправляет confirm обратно. ответить на confirm_request, и отправляет confirm обратно.
Три режима confirm (из закрытых решений Этапа 8): Три режима confirm:
standalone/interactive (display, mqs, usd): standalone/interactive (display, mqs, usd):
TUI показывает prompt оператору + кнопки OK/FAIL + countdown TUI показывает prompt оператору + кнопки OK/FAIL + countdown
@ -21,7 +21,7 @@ orchestrator.py — оркестратор confirm_request для TUI.
отправляет confirm без участия оператора отправляет confirm без участия оператора
оператор видит только прогресс оператор видит только прогресс
Маршрутизация определяется по prefix confirm_request.id: Маршрутизация определяется по id confirm_request:
"opto_*" HIL (M5 relay) "opto_*" HIL (M5 relay)
"can_rx_ready" HIL (M5 can_send) "can_rx_ready" HIL (M5 can_send)
"can_tx_verify" HIL (M5 can_recv + verify) "can_tx_verify" HIL (M5 can_recv + verify)
@ -42,16 +42,16 @@ from typing import AsyncGenerator, Callable, Coroutine, Optional
from .firmware_client import FirmwareClient from .firmware_client import FirmwareClient
from .m5_client import M5Client from .m5_client import M5Client
from .models import ConfirmRequest, TestInfo, TestResult, TestStatus from .models import ConfirmRequest, TestResult, TestStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Задержки для HIL (из закрытых решений: RELAY_ON_S=0.15, RELAY_OFF_S=0.5) # Задержки для HIL
_RELAY_ON_S = 0.15 _RELAY_ON_S = 0.15
_RELAY_OFF_S = 0.50 _RELAY_OFF_S = 0.50
# Карта confirm_id → реле M5 для opto-теста # Карта confirm_id → реле M5 для opto-теста
# Формат: confirm_id_prefix → (relay_num, target_state) # Формат: confirm_id → (relay_num, target_state)
_OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = { _OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
"opto_in1_active": (3, True), "opto_in1_active": (3, True),
"opto_in1_inactive": (3, False), "opto_in1_inactive": (3, False),
@ -61,7 +61,7 @@ _OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
"opto_rs_inactive": (2, False), "opto_rs_inactive": (2, False),
} }
# CAN параметры (из закрытых решений) # CAN параметры
_CAN_RX_ID = 0x100 _CAN_RX_ID = 0x100
_CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF] _CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
_CAN_TX_ID = 0x200 _CAN_TX_ID = 0x200
@ -70,6 +70,7 @@ _CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE]
# ── Типы событий оркестратора ──────────────────────────────────────────── # ── Типы событий оркестратора ────────────────────────────────────────────
class OrchestratorEventType(Enum): class OrchestratorEventType(Enum):
TEST_BEGIN = auto() # тест начался TEST_BEGIN = auto() # тест начался
TEST_RESULT = auto() # тест завершился TEST_RESULT = auto() # тест завершился
@ -82,7 +83,7 @@ class OrchestratorEventType(Enum):
@dataclass @dataclass
class OrchestratorEvent: class OrchestratorEvent:
"""Событие от оркестратора — передаётся в TUI через asyncio.Queue.""" """Событие от оркестратора — передаётся в TUI."""
type: OrchestratorEventType type: OrchestratorEventType
test_id: str = "" test_id: str = ""
@ -93,8 +94,7 @@ class OrchestratorEvent:
message: str = "" message: str = ""
# Тип callback для ответа оператора: # Тип callback для ответа оператора
# TUI вызывает его с True (OK) или False (FAIL) после показа prompt
OperatorConfirmCallback = Callable[[bool], Coroutine] OperatorConfirmCallback = Callable[[bool], Coroutine]
@ -107,7 +107,6 @@ class Orchestrator:
orch = Orchestrator(firmware_client, m5_client) orch = Orchestrator(firmware_client, m5_client)
async for event in orch.run_tests(["sdram", "opto", "display"]): async for event in orch.run_tests(["sdram", "opto", "display"]):
if event.type == OrchestratorEventType.CONFIRM_NEEDED: if event.type == OrchestratorEventType.CONFIRM_NEEDED:
# TUI показывает prompt, получает ответ оператора
confirmed = await tui.ask_operator(event.confirm) confirmed = await tui.ask_operator(event.confirm)
await orch.resolve_operator_confirm(confirmed) await orch.resolve_operator_confirm(confirmed)
elif event.type == OrchestratorEventType.TEST_RESULT: elif event.type == OrchestratorEventType.TEST_RESULT:
@ -121,7 +120,6 @@ class Orchestrator:
) -> None: ) -> None:
self._fw = firmware self._fw = firmware
self._m5 = m5 self._m5 = m5
# Queue для ответов оператора на CONFIRM_NEEDED
self._operator_queue: asyncio.Queue[bool] = asyncio.Queue(maxsize=1) self._operator_queue: asyncio.Queue[bool] = asyncio.Queue(maxsize=1)
async def resolve_operator_confirm(self, confirmed: bool) -> None: async def resolve_operator_confirm(self, confirmed: bool) -> None:
@ -151,12 +149,11 @@ class Orchestrator:
) )
elif event_type == "test_result": elif event_type == "test_result":
status_str = raw.get("status", "fail")
status = { status = {
"pass": TestStatus.PASS, "pass": TestStatus.PASS,
"fail": TestStatus.FAIL, "fail": TestStatus.FAIL,
"skip": TestStatus.SKIP, "skip": TestStatus.SKIP,
}.get(status_str, TestStatus.FAIL) }.get(raw.get("status", "fail"), TestStatus.FAIL)
result = TestResult( result = TestResult(
id=raw.get("id", ""), id=raw.get("id", ""),
@ -176,8 +173,8 @@ class Orchestrator:
prompt=raw.get("prompt", ""), prompt=raw.get("prompt", ""),
timeout_ms=raw.get("timeout_ms", 30000), timeout_ms=raw.get("timeout_ms", 30000),
) )
async for orch_event in self._handle_confirm(confirm): async for ev in self._handle_confirm(confirm):
yield orch_event yield ev
elif event_type == "summary": elif event_type == "summary":
yield OrchestratorEvent( yield OrchestratorEvent(
@ -185,10 +182,10 @@ class Orchestrator:
summary=raw, summary=raw,
) )
elif event_type in ("error", None): else:
yield OrchestratorEvent( yield OrchestratorEvent(
type=OrchestratorEventType.ERROR, type=OrchestratorEventType.ERROR,
message=raw.get("error", "unknown error"), message=raw.get("error", f"unknown event: {event_type}"),
) )
# ── Маршрутизация confirm ──────────────────────────────────────────── # ── Маршрутизация confirm ────────────────────────────────────────────
@ -199,25 +196,21 @@ class Orchestrator:
"""Определить тип confirm и обработать соответственно.""" """Определить тип confirm и обработать соответственно."""
cid = confirm.id cid = confirm.id
# HIL: opto-тест
if cid in _OPTO_RELAY_MAP: if cid in _OPTO_RELAY_MAP:
async for ev in self._handle_hil_opto(confirm): async for ev in self._handle_hil_opto(confirm):
yield ev yield ev
return return
# HIL: CAN RX
if cid == "can_rx_ready": if cid == "can_rx_ready":
async for ev in self._handle_hil_can_rx(confirm): async for ev in self._handle_hil_can_rx(confirm):
yield ev yield ev
return return
# HIL: CAN TX verify
if cid == "can_tx_verify": if cid == "can_tx_verify":
async for ev in self._handle_hil_can_tx(confirm): async for ev in self._handle_hil_can_tx(confirm):
yield ev yield ev
return return
# buttons: нет JSON-confirm, только инструкция оператору
if cid.startswith("btn"): if cid.startswith("btn"):
yield OrchestratorEvent( yield OrchestratorEvent(
type=OrchestratorEventType.BUTTONS_PROMPT, type=OrchestratorEventType.BUTTONS_PROMPT,
@ -225,7 +218,6 @@ class Orchestrator:
) )
return return
# standalone interactive: показать оператору
async for ev in self._handle_operator_confirm(confirm): async for ev in self._handle_operator_confirm(confirm):
yield ev yield ev
@ -248,7 +240,6 @@ class Orchestrator:
settle_s = _RELAY_ON_S if relay_state else _RELAY_OFF_S settle_s = _RELAY_ON_S if relay_state else _RELAY_OFF_S
ok = await self._m5.relay_set(relay_num, relay_state) ok = await self._m5.relay_set(relay_num, relay_state)
await asyncio.sleep(settle_s) await asyncio.sleep(settle_s)
await self._fw.send_confirm(confirm.id, ok) await self._fw.send_confirm(confirm.id, ok)
yield OrchestratorEvent( yield OrchestratorEvent(
@ -270,7 +261,6 @@ class Orchestrator:
) )
return return
# M5 отправляет CAN-фрейм, таргет принимает после confirmed:true
ok = await self._m5.can_send(_CAN_RX_ID, _CAN_RX_DATA) ok = await self._m5.can_send(_CAN_RX_ID, _CAN_RX_DATA)
await self._fw.send_confirm(confirm.id, ok) await self._fw.send_confirm(confirm.id, ok)
@ -293,7 +283,6 @@ class Orchestrator:
) )
return return
# Таргет уже отправил фрейм, M5 принимает и верифицирует
frame = await self._m5.can_recv(timeout_ms=500) frame = await self._m5.can_recv(timeout_ms=500)
verified = ( verified = (
frame is not None frame is not None
@ -322,7 +311,6 @@ class Orchestrator:
Передать confirm оператору. Блокируется до resolve_operator_confirm(). Передать confirm оператору. Блокируется до resolve_operator_confirm().
TUI должен показать prompt и дать возможность ответить. TUI должен показать prompt и дать возможность ответить.
""" """
# очистить очередь на случай устаревшего ответа
while not self._operator_queue.empty(): while not self._operator_queue.empty():
self._operator_queue.get_nowait() self._operator_queue.get_nowait()
@ -331,7 +319,6 @@ class Orchestrator:
confirm=confirm, confirm=confirm,
) )
# ждём ответа от оператора (TUI вызовет resolve_operator_confirm)
timeout_s = confirm.timeout_ms / 1000.0 timeout_s = confirm.timeout_ms / 1000.0
try: try:
confirmed = await asyncio.wait_for( confirmed = await asyncio.wait_for(

View file

@ -2,6 +2,7 @@
from .diag import DiagScreen from .diag import DiagScreen
from .flash import FlashScreen from .flash import FlashScreen
from .post_flash import PostFlashScreen
from .waiting import WaitingScreen from .waiting import WaitingScreen
__all__ = ["WaitingScreen", "FlashScreen", "DiagScreen"] __all__ = ["WaitingScreen", "FlashScreen", "PostFlashScreen", "DiagScreen"]

View file

@ -17,7 +17,7 @@ from typing import Optional
from textual import on, work from textual import on, work
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.message import Message from textual.message import Message
from textual.screen import Screen from textual.screen import Screen
@ -27,6 +27,7 @@ from ...firmware_client import FirmwareClient
from ...m5_client import M5Client from ...m5_client import M5Client
from ...models import SessionState from ...models import SessionState
from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType
from ...widgets import AppFrame
from .confirm_panel import ConfirmPanel from .confirm_panel import ConfirmPanel
from .results import ResultsPanel from .results import ResultsPanel
from .test_list import TestListPanel from .test_list import TestListPanel
@ -55,32 +56,38 @@ class DiagScreen(Screen):
self, self,
firmware: FirmwareClient, firmware: FirmwareClient,
m5: Optional[M5Client] = None, m5: Optional[M5Client] = None,
fw_version: str = "",
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._fw = firmware self._fw = firmware
self._m5 = m5 self._m5 = m5
self._orchestrator = Orchestrator(firmware, m5) self._orchestrator = Orchestrator(firmware, m5)
self._session = SessionState() self._session = SessionState(fw_version=fw_version)
self._running = False self._tests_running = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
# Шапка with AppFrame(id="diag-frame"):
# Шапка — фиксированная высота 3
with Horizontal(id="diag-header"): with Horizontal(id="diag-header"):
yield Static("fw: —", id="diag-header-fw") yield Static("firmware_test: —\nFw: —", id="diag-header-fw")
yield Static("UID: —", id="diag-header-uid") yield Static("MCU ID: —", id="diag-header-uid")
yield Static("M5: —", id="diag-header-m5") yield Static("M5: —", id="diag-header-m5")
# Рабочая зона: список тестов + результаты # Рабочая зона: список тестов + результаты — занимает всё
# оставшееся место (1fr), сама прокручивается при переполнении
with Horizontal(id="diag-main"): with Horizontal(id="diag-main"):
yield TestListPanel(id="diag-test-list") yield TestListPanel(id="diag-test-list")
yield ResultsPanel(id="diag-results") yield ResultsPanel(id="diag-results")
# Прогресс # Прогресс — скрыт пока нет активного прогона. Контейнер
# имеет classes="hidden" по умолчанию — height: auto + display:
# none даёт нулевую высоту, не отнимая место у остального layout.
with Vertical(id="diag-progress-row", classes="hidden"):
yield ProgressBar(id="diag-progress-bar", show_eta=False) yield ProgressBar(id="diag-progress-bar", show_eta=False)
yield Label("", id="diag-progress-label") yield Label("", id="diag-progress-label")
# Кнопки запуска # Кнопки запуска — фиксированная высота 3 (под border Button)
with Horizontal(id="diag-btn-row"): with Horizontal(id="diag-btn-row"):
yield Button( yield Button(
"▶ Запустить выбранные", "▶ Запустить выбранные",
@ -94,8 +101,9 @@ class DiagScreen(Screen):
variant="default", variant="default",
disabled=True, disabled=True,
) )
yield Button("✕ Выйти", id="diag-btn-quit", variant="default")
# Панель confirm # Панель confirm — auto-высота, видна только когда есть запрос
yield ConfirmPanel(id="diag-confirm", classes="hidden") yield ConfirmPanel(id="diag-confirm", classes="hidden")
def on_mount(self) -> None: def on_mount(self) -> None:
@ -108,7 +116,6 @@ class DiagScreen(Screen):
try: try:
tests = await self._fw.list_tests() tests = await self._fw.list_tests()
uid = await self._fw.get_uid() uid = await self._fw.get_uid()
version = await self._fw.get_version()
except Exception as exc: except Exception as exc:
logger.error("Session init failed: %s", exc) logger.error("Session init failed: %s", exc)
self.post_message(self.DiagDone()) self.post_message(self.DiagDone())
@ -116,7 +123,6 @@ class DiagScreen(Screen):
self._session.tests = tests self._session.tests = tests
self._session.chip_uid = uid self._session.chip_uid = uid
self._session.fw_version = version
self._session.m5_connected = self._m5 is not None self._session.m5_connected = self._m5 is not None
self._update_header() self._update_header()
@ -129,17 +135,17 @@ class DiagScreen(Screen):
def _update_header(self) -> None: def _update_header(self) -> None:
self.query_one("#diag-header-fw", Static).update( self.query_one("#diag-header-fw", Static).update(
f"fw: {self._session.fw_version or '?'}" f"firmware_test: {self._session.fw_version or '?'}"
) )
uid_short = self._session.chip_uid[:16] if self._session.chip_uid else "" uid = self._session.chip_uid or ""
self.query_one("#diag-header-uid", Static).update(f"UID: {uid_short}") self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}")
m5_widget = self.query_one("#diag-header-m5", Static) m5_widget = self.query_one("#diag-header-m5", Static)
if self._session.m5_connected: if self._session.m5_connected:
m5_widget.update("M5: ✓ подключён") m5_widget.update("M5: ✓ подключён")
m5_widget.remove_class("m5-absent") m5_widget.remove_class("m5-absent")
else: else:
m5_widget.update("M5: — нет") m5_widget.update("M5: ✕ не подключен")
m5_widget.add_class("m5-absent") m5_widget.add_class("m5-absent")
# ── Кнопки ─────────────────────────────────────────────────────────────── # ── Кнопки ───────────────────────────────────────────────────────────────
@ -156,8 +162,13 @@ class DiagScreen(Screen):
if ids: if ids:
self._start_run(ids) self._start_run(ids)
@on(Button.Pressed, "#diag-btn-quit")
def _on_quit_pressed(self) -> None:
if not self._tests_running:
self.app.exit()
def action_go_back(self) -> None: def action_go_back(self) -> None:
if not self._running: if not self._tests_running:
self.post_message(self.DiagDone()) self.post_message(self.DiagDone())
def action_run_all(self) -> None: def action_run_all(self) -> None:
@ -181,9 +192,11 @@ class DiagScreen(Screen):
results = self.query_one("#diag-results", ResultsPanel) results = self.query_one("#diag-results", ResultsPanel)
results.reset() results.reset()
self._session.results.clear() self._session.results.clear()
self._show_progress(True)
self._update_progress(0, len(test_ids), "") self._update_progress(0, len(test_ids), "")
self._set_run_buttons(enabled=False) self._set_run_buttons(enabled=False)
self._running = True self.query_one("#diag-test-list", TestListPanel).set_enabled(False)
self._tests_running = True
self._run_worker(test_ids) self._run_worker(test_ids)
@work(exclusive=True, thread=False) @work(exclusive=True, thread=False)
@ -200,8 +213,9 @@ class DiagScreen(Screen):
except Exception as exc: except Exception as exc:
logger.error("run_worker error: %s", exc) logger.error("run_worker error: %s", exc)
finally: finally:
self._running = False self._tests_running = False
self._set_run_buttons(enabled=True) self._set_run_buttons(enabled=True)
self.query_one("#diag-test-list", TestListPanel).set_enabled(True)
try: try:
self.query_one("#diag-confirm", ConfirmPanel).hide() self.query_one("#diag-confirm", ConfirmPanel).hide()
except NoMatches: except NoMatches:
@ -251,9 +265,21 @@ class DiagScreen(Screen):
# ── Утилиты ─────────────────────────────────────────────────────────────── # ── Утилиты ───────────────────────────────────────────────────────────────
def _show_progress(self, visible: bool) -> None:
"""Показать/скрыть строку прогресса. Скрыта в простое — без анимации."""
row = self.query_one("#diag-progress-row")
bar = self.query_one("#diag-progress-bar", ProgressBar)
if visible:
bar.update(total=100, progress=0)
row.remove_class("hidden")
else:
row.add_class("hidden")
def _update_progress(self, done: int, total: int, msg: str) -> None: def _update_progress(self, done: int, total: int, msg: str) -> None:
pct = int(done / total * 100) if total else 0 pct = int(done / total * 100) if total else 0
self.query_one("#diag-progress-bar", ProgressBar).update(progress=pct) self.query_one("#diag-progress-bar", ProgressBar).update(
total=100, progress=pct
)
self.query_one("#diag-progress-label", Label).update(msg) self.query_one("#diag-progress-label", Label).update(msg)
def _set_run_buttons(self, enabled: bool) -> None: def _set_run_buttons(self, enabled: bool) -> None:

View file

@ -28,7 +28,7 @@ class ConfirmPanel(Widget):
Использование:: Использование::
panel = ConfirmPanel() panel = ConfirmPanel()
panel.show(prompt="Экран залит красным?", timeout_ms=30000) panel.show_operator(prompt="Экран залит красным?", timeout_ms=30000)
# Слушать ConfirmPanel.Confirmed в родительском экране # Слушать ConfirmPanel.Confirmed в родительском экране
""" """
@ -39,13 +39,10 @@ class ConfirmPanel(Widget):
super().__init__() super().__init__()
self.confirmed = confirmed self.confirmed = confirmed
DEFAULT_CSS = "" # стили в diag.tcss
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._timer: Timer | None = None self._timer: Timer | None = None
self._remaining: int = 0 self._remaining: int = 0
self._operator_mode: bool = True # False → режим buttons (нет кнопок)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static("", id="confirm-prompt") yield Static("", id="confirm-prompt")
@ -57,9 +54,8 @@ class ConfirmPanel(Widget):
def show_operator(self, prompt: str, timeout_ms: int) -> None: def show_operator(self, prompt: str, timeout_ms: int) -> None:
"""Показать панель с кнопками OK/Нет и countdown.""" """Показать панель с кнопками OK/Нет и countdown."""
self._operator_mode = True
self._remaining = timeout_ms // 1000 self._remaining = timeout_ms // 1000
self._set_prompt(prompt) self._set_prompt(f"{prompt}")
self._set_countdown(self._remaining) self._set_countdown(self._remaining)
self._show_buttons(True) self._show_buttons(True)
self.remove_class("hidden") self.remove_class("hidden")
@ -70,7 +66,6 @@ class ConfirmPanel(Widget):
Показать инструкцию для теста кнопок. Показать инструкцию для теста кнопок.
Без кнопок OK/Нет оператор только читает, нажимает физическую кнопку. Без кнопок OK/Нет оператор только читает, нажимает физическую кнопку.
""" """
self._operator_mode = False
self._stop_timer() self._stop_timer()
self._set_prompt(f"{prompt}") self._set_prompt(f"{prompt}")
self._set_countdown("") self._set_countdown("")
@ -117,7 +112,7 @@ class ConfirmPanel(Widget):
def _set_prompt(self, text: str) -> None: def _set_prompt(self, text: str) -> None:
try: try:
self.query_one("#confirm-prompt", Static).update(f"{text}") self.query_one("#confirm-prompt", Static).update(text)
except NoMatches: except NoMatches:
pass pass

View file

@ -1,115 +1,212 @@
""" """
results.py виджет правой колонки DiagScreen. results.py виджет правой колонки DiagScreen.
Отображает строки результатов тестов: id | статус | detail. DataTable с колонками: Тест | HIL | Статус | Время | Детали.
Обновляется по событиям от Orchestrator через DiagScreen. До первого запуска показывает empty-state placeholder вместо таблицы.
Сортировка: FAIL всегда наверху (естественно бросается в глаза),
внутри групп статусов исходный порядок реестра firmware_test.
Строка с FAIL дополнительно подсвечивается красным фоном целиком.
Публичный API: Публичный API:
ResultsPanel.populate(tests) инициализировать пустые строки ResultsPanel.populate(tests) показать пустую таблицу (PENDING)
ResultsPanel.set_running(test_id) показать "выполняется" ResultsPanel.set_running(test_id) пометить тест как выполняющийся
ResultsPanel.set_result(result) показать финальный результат ResultsPanel.set_result(result) показать финальный результат
ResultsPanel.reset() сбросить все строки ResultsPanel.reset() сбросить все строки в PENDING
""" """
from __future__ import annotations from __future__ import annotations
from rich.style import Style
from rich.text import Text
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal from textual.containers import Center, Middle
from textual.css.query import NoMatches
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Label, Static from textual.widgets import DataTable, Static
from ...models import TestResult, TestStatus from ...models import TestInfo, TestResult, TestStatus
# Порядок сортировки: чем меньше число — тем выше строка в таблице.
# FAIL всегда наверху, PASS/SKIP внизу — внутри групп сохраняется
# исходный порядок реестра (стабильная сортировка).
_SORT_RANK: dict[TestStatus, int] = {
TestStatus.FAIL: 0,
TestStatus.RUNNING: 1,
TestStatus.PENDING: 2,
TestStatus.SKIP: 3,
TestStatus.PASS: 4,
}
def _status_display(status: TestStatus) -> tuple[str, str]: _STATUS_DISPLAY: dict[TestStatus, tuple[str, str]] = {
"""Вернуть (текст, css-класс) для статуса.""" TestStatus.PASS: ("✓ PASS", "green"),
return { TestStatus.FAIL: ("✗ FAIL", "bold white"),
TestStatus.PASS: ("✓ PASS", "result-status-pass"), TestStatus.RUNNING: ("", "yellow"),
TestStatus.FAIL: ("✗ FAIL", "result-status-fail"), TestStatus.SKIP: ("SKIP", "grey50"),
TestStatus.RUNNING: ("", "result-status-running"), TestStatus.PENDING: ("", "grey50"),
TestStatus.SKIP: ("SKIP", "result-status-skip"), }
TestStatus.PENDING: ("", "result-status-skip"),
}[status]
_FAIL_BG = "dark_red"
# CSS-классы статусов — для очистки перед сменой
_STATUS_CLASSES = (
"result-status-pass",
"result-status-fail",
"result-status-running",
"result-status-skip",
)
class ResultsPanel(Widget): class ResultsPanel(Widget):
"""Правая колонка DiagScreen — результаты тестов.""" """Правая колонка DiagScreen — таблица результатов тестов."""
DEFAULT_CSS = "" # стили в diag.tcss def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# test_id → TestInfo, нужно для перестроения строк при сортировке
self._tests: dict[str, TestInfo] = {}
# test_id → текущий статус (для сортировки и повторных update)
self._statuses: dict[str, TestStatus] = {}
self._order: list[str] = [] # исходный порядок реестра
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Результаты", classes="section-title") with Center(id="results-empty"):
with Middle():
yield Static(
"Выберите тесты и нажмите\n«Запустить выбранные»",
id="results-empty-text",
)
yield DataTable(id="results-table", cursor_type="row", classes="hidden")
def on_mount(self) -> None:
table = self.query_one("#results-table", DataTable)
table.add_columns("Тест", "M5", "Статус", "Время", "Детали")
# ── Public API ──────────────────────────────────────────────────────────── # ── Public API ────────────────────────────────────────────────────────────
def populate(self, tests: list) -> None: def populate(self, tests: list[TestInfo]) -> None:
""" """
Инициализировать пустые строки результатов. Заполнить таблицу тестами в состоянии PENDING.
До первого вызова populate() с непустым списком виден empty-state.
"""
table = self.query_one("#results-table", DataTable)
table.clear()
self._tests.clear()
self._statuses.clear()
self._order = [t.id for t in tests]
:param tests: list[TestInfo] if not tests:
""" self._show_empty(True)
for child in list(self.children): return
if not child.has_class("section-title"):
child.remove()
for test in tests: for test in tests:
row = Horizontal(classes="result-row", id=f"result-row-{test.id}") self._tests[test.id] = test
id_lbl = Static(test.id, classes="result-id") self._statuses[test.id] = TestStatus.PENDING
status_lbl = Static( self._add_row(test, TestStatus.PENDING, duration_ms=0, detail="")
"", classes="result-status-skip", id=f"result-status-{test.id}"
) self._show_empty(False)
detail_lbl = Static(
"", classes="result-detail", id=f"result-detail-{test.id}"
)
self.mount(row)
row.mount(id_lbl)
row.mount(status_lbl)
row.mount(detail_lbl)
def set_running(self, test_id: str) -> None: def set_running(self, test_id: str) -> None:
"""Пометить тест как выполняющийся.""" """Пометить тест как выполняющийся."""
self._update(test_id, TestStatus.RUNNING, "") self._update_row(test_id, TestStatus.RUNNING, duration_ms=0, detail="")
def set_result(self, result: TestResult) -> None: def set_result(self, result: TestResult) -> None:
"""Показать финальный результат теста.""" """Показать финальный результат теста."""
detail = result.detail if result.status == TestStatus.FAIL else "" detail = result.detail if result.status == TestStatus.FAIL else ""
self._update(result.id, result.status, detail) self._update_row(result.id, result.status, result.duration_ms, detail)
def reset(self) -> None: def reset(self) -> None:
"""Сбросить все строки в пустое состояние.""" """Сбросить все строки в PENDING (перед повторным запуском)."""
for widget in self.query(Static): for test_id in list(self._tests.keys()):
wid = widget.id or "" self._update_row(test_id, TestStatus.PENDING, duration_ms=0, detail="")
if wid.startswith("result-status-"):
for cls in _STATUS_CLASSES:
widget.remove_class(cls)
widget.add_class("result-status-skip")
widget.update("")
elif wid.startswith("result-detail-"):
widget.update("")
# ── Internal ────────────────────────────────────────────────────────────── # ── Internal: empty state ───────────────────────────────────────────────
def _update(self, test_id: str, status: TestStatus, detail: str) -> None: def _show_empty(self, visible: bool) -> None:
try: empty = self.query_one("#results-empty")
status_w = self.query_one(f"#result-status-{test_id}", Static) table = self.query_one("#results-table", DataTable)
detail_w = self.query_one(f"#result-detail-{test_id}", Static) if visible:
except NoMatches: empty.remove_class("hidden")
table.add_class("hidden")
else:
empty.add_class("hidden")
table.remove_class("hidden")
# ── Internal: строки таблицы ────────────────────────────────────────────
def _add_row(
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
) -> None:
table = self.query_one("#results-table", DataTable)
cells = self._row_cells(test, status, duration_ms, detail)
table.add_row(*cells, key=test.id)
def _update_row(
self, test_id: str, status: TestStatus, duration_ms: int, detail: str
) -> None:
test = self._tests.get(test_id)
if test is None:
return return
self._statuses[test_id] = status
for cls in _STATUS_CLASSES: table = self.query_one("#results-table", DataTable)
status_w.remove_class(cls) cells = self._row_cells(test, status, duration_ms, detail)
col_keys = [c.key for c in table.ordered_columns]
for col_key, value in zip(col_keys, cells):
table.update_cell(test_id, col_key, value)
text, css = _status_display(status) self._resort(table)
status_w.update(text)
status_w.add_class(css) def _row_cells(
detail_w.update(detail) self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
) -> tuple:
"""Собрать пять ячеек строки с учётом подсветки FAIL."""
status_text, status_color = _STATUS_DISPLAY[status]
is_fail = status == TestStatus.FAIL
bg = _FAIL_BG if is_fail else None
fg = "white" if is_fail else status_color
name_style = Style(bgcolor=bg, bold=is_fail)
hil_style = Style(bgcolor=bg, color="white" if is_fail else "cyan")
status_style = Style(bgcolor=bg, color=fg, bold=True)
time_style = Style(bgcolor=bg, color="white" if is_fail else "grey70")
detail_style = Style(bgcolor=bg, color="white" if is_fail else "grey50")
time_text = f"{duration_ms / 1000:.1f}с" if duration_ms > 0 else ""
hil_text = "[*]" if test.requires_hil else "[-]"
return (
Text(test.name, style=name_style),
Text(hil_text, style=hil_style),
Text(status_text, style=status_style),
Text(time_text, style=time_style),
Text(detail, style=detail_style),
)
def _resort(self, table: DataTable) -> None:
"""
Пересортировать: FAIL наверх, дальше RUNNING/PENDING/SKIP/PASS.
Внутри групп исходный порядок реестра (стабильная сортировка).
DataTable.sort(*columns, key=...) передаёт в key() кортеж значений
ЯЧЕЕК (не row_key) для указанных columns поэтому сортируем по
содержимому самой ячейки "Тест" (используем как индекс в self._order)
и по тексту статуса, который мы сами туда пишем и полностью
контролируем никаких приватных атрибутов DataTable не трогаем.
"""
name_col = table.ordered_columns[0].key
status_col = table.ordered_columns[2].key
# text -> status enum, обратное к _STATUS_DISPLAY
status_by_text = {text: status for status, (text, _) in _STATUS_DISPLAY.items()}
# имя теста -> индекс в исходном реестре (для стабильности внутри группы)
name_to_order = {
self._tests[tid].name: idx
for idx, tid in enumerate(self._order)
if tid in self._tests
}
def sort_key(cells: tuple) -> tuple:
name_cell, status_cell = cells
name_plain = (
name_cell.plain if hasattr(name_cell, "plain") else str(name_cell)
)
status_plain = (
status_cell.plain if hasattr(status_cell, "plain") else str(status_cell)
)
status = status_by_text.get(status_plain, TestStatus.PENDING)
order_idx = name_to_order.get(name_plain, 0)
return (_SORT_RANK.get(status, 99), order_idx)
table.sort(name_col, status_col, key=sort_key)

View file

@ -12,46 +12,63 @@ test_list.py — виджет списка тестов с чекбоксами.
from __future__ import annotations from __future__ import annotations
from textual import on
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Checkbox, Label from textual.widgets import Button, Checkbox, Label
class TestListPanel(Widget): class TestListPanel(Widget):
"""Левая колонка DiagScreen — список тестов с чекбоксами.""" """Левая колонка DiagScreen — список тестов с чекбоксами."""
DEFAULT_CSS = "" # стили в diag.tcss
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
# test_id → Checkbox для быстрого доступа # test_id → Checkbox для быстрого доступа
self._checkboxes: dict[str, Checkbox] = {} self._checkboxes: dict[str, Checkbox] = {}
# test_id → True если HIL-тест недоступен без M5 (постоянное состояние,
# не зависящее от прогона). Отдельно от Checkbox.disabled, который
# временно перещёлкивается на время прогона тестов через set_enabled().
self._hil_unavailable: dict[str, bool] = {}
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Тесты", classes="section-title") yield Label("Тесты", classes="section-title")
with Horizontal(id="test-list-select-row"):
yield Button(
"Выбрать все", id="test-list-select-all", classes="-textual-compact"
)
yield Button(
"Снять все", id="test-list-select-none", classes="-textual-compact"
)
# ── Public API ──────────────────────────────────────────────────────────── # ── Public API ────────────────────────────────────────────────────────────
def populate(self, tests: list, m5_connected: bool) -> None: def populate(self, tests: list, m5_connected: bool) -> None:
""" """
Заполнить список тестами. Заполнить список тестами. Изначально все тесты НЕ выбраны
(сервисник выбирает явно что запускать) кроме HIL без M5,
которые всегда disabled.
:param tests: list[TestInfo] :param tests: list[TestInfo]
:param m5_connected: True если M5StampPLC подключён :param m5_connected: True если M5StampPLC подключён
""" """
# Удалить старые строки (кроме заголовка) # Удалить старые строки (кроме заголовка и select-row)
for child in list(self.children): for child in list(self.children):
if not child.has_class("section-title"): if child.id != "test-list-select-row" and not child.has_class(
"section-title"
):
child.remove() child.remove()
self._checkboxes.clear() self._checkboxes.clear()
self._hil_unavailable.clear()
rows = []
for test in tests: for test in tests:
hil_unavailable = test.requires_hil and not m5_connected hil_unavailable = test.requires_hil and not m5_connected
self._hil_unavailable[test.id] = hil_unavailable
cb = Checkbox( cb = Checkbox(
test.name, test.name,
value=not hil_unavailable, value=False,
disabled=hil_unavailable, disabled=hil_unavailable,
id=f"cb-{test.id}", id=f"cb-{test.id}",
classes="-textual-compact", classes="-textual-compact",
@ -65,10 +82,16 @@ class TestListPanel(Widget):
) )
badge_text = "[HIL]" if test.requires_hil else "" badge_text = "[HIL]" if test.requires_hil else ""
row = Horizontal(classes="test-row") rows.append(
self.mount(row) Horizontal(
row.mount(cb) cb,
row.mount(Label(badge_text, classes=hil_css)) Label(badge_text, classes=hil_css),
classes="test-row",
)
)
if rows:
self.mount_all(rows)
def get_selected_ids(self) -> list[str]: def get_selected_ids(self) -> list[str]:
"""Вернуть список id выбранных (checked + not disabled) тестов.""" """Вернуть список id выбранных (checked + not disabled) тестов."""
@ -77,7 +100,31 @@ class TestListPanel(Widget):
] ]
def set_enabled(self, enabled: bool) -> None: def set_enabled(self, enabled: bool) -> None:
"""Разрешить/запретить изменение чекбоксов во время прогона.""" """
for cb in self._checkboxes.values(): Разрешить/запретить изменение чекбоксов во время прогона.
if not cb.disabled: # не трогать серые HIL-без-M5
HIL-чекбоксы без M5 остаются disabled всегда их постоянное
состояние хранится в self._hil_unavailable и не зависит от прогона.
"""
for tid, cb in self._checkboxes.items():
if self._hil_unavailable.get(tid, False):
cb.disabled = True # HIL без M5 — всегда недоступен
else:
cb.disabled = not enabled cb.disabled = not enabled
self.query_one("#test-list-select-all", Button).disabled = not enabled
self.query_one("#test-list-select-none", Button).disabled = not enabled
# ── Обработчики ───────────────────────────────────────────────────────────
@on(Button.Pressed, "#test-list-select-all")
def _on_select_all(self) -> None:
for tid, cb in self._checkboxes.items():
if not self._hil_unavailable.get(tid, False):
cb.value = True
@on(Button.Pressed, "#test-list-select-none")
def _on_select_none(self) -> None:
for tid, cb in self._checkboxes.items():
if not self._hil_unavailable.get(tid, False):
cb.value = False

View file

@ -30,6 +30,7 @@ from textual.widgets import (
from ..flasher import Flasher from ..flasher import Flasher
from ..models import FlashProgress, FlashTarget from ..models import FlashProgress, FlashTarget
from ..widgets import AppFrame
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,7 +40,7 @@ class FlashScreen(Screen):
Экран прошивки. Экран прошивки.
Messages: Messages:
FlashDone(success) прошивка завершена FlashDone(success, target) прошивка завершена
""" """
BINDINGS = [ BINDINGS = [
@ -47,9 +48,10 @@ class FlashScreen(Screen):
] ]
class FlashDone(Message): class FlashDone(Message):
def __init__(self, success: bool) -> None: def __init__(self, success: bool, target: Optional[FlashTarget] = None) -> None:
super().__init__() super().__init__()
self.success = success self.success = success
self.target = target
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@ -57,48 +59,42 @@ class FlashScreen(Screen):
self._flashing = False self._flashing = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with AppFrame(id="flash-frame"):
yield Label( yield Label(
"Прошивка платы — BootROM SDP обнаружен", "Загрузка прошивки на плату (BootROM SDP обнаружен)",
id="flash-title", id="flash-title",
) )
with Vertical(id="flash-target-group"): with Vertical(id="flash-target-group"):
yield Label("Что прошить?", classes="section-title") yield Label("Выберите файл для загрузки", classes="section-title")
with RadioSet(id="flash-radio"): with RadioSet(id="flash-radio"):
yield RadioButton( yield RadioButton(
"firmware_test (диагностическая прошивка)", "Диагностическая прошивка (firmware_test)",
id="radio-fw-test", id="radio-fw-test",
value=True, value=True,
) )
yield RadioButton( yield RadioButton(
"Production (bootloader + tft_app)", "Серийная прошивка (bootloader + tft_app)",
id="radio-production", id="radio-production",
) )
yield RadioButton( yield RadioButton(
"Кастомный бинарь...", "Другое (подготовленный бинарный файл)",
id="radio-custom", id="radio-custom",
) )
with Horizontal(id="flash-custom-path", classes="hidden"): with Horizontal(id="flash-custom-path", classes="hidden"):
yield Input( yield Input(
placeholder="Путь к HAB-бинарю (.bin)", placeholder="Путь к бинарному файлу (.bin)",
id="flash-custom-input", id="flash-custom-input",
) )
with Horizontal(id="flash-btn-row"): with Horizontal(id="flash-btn-row"):
yield Button("▶ Загрузить ПО", id="flash-btn-flash", variant="warning")
yield Button("⚠ Очистить память", id="flash-btn-erase", variant="error")
yield Button( yield Button(
"▶ Прошить", "✕ Выйти из программы", id="flash-btn-quit", variant="default"
id="flash-btn-flash",
variant="warning",
)
yield Button(
"⚠ Chip Erase",
id="flash-btn-erase",
variant="error",
) )
yield ProgressBar(id="flash-progress-bar", show_eta=False) yield ProgressBar(id="flash-progress-bar", show_eta=False, classes="hidden")
yield Label("", id="flash-progress-label")
yield Log(id="flash-log", auto_scroll=True) yield Log(id="flash-log", auto_scroll=True)
# ── Обработчики ─────────────────────────────────────────────────────────── # ── Обработчики ───────────────────────────────────────────────────────────
@ -118,16 +114,20 @@ class FlashScreen(Screen):
return return
target, bin_path = self._resolve_target() target, bin_path = self._resolve_target()
if target is None: if target is None:
self._log("⚠ Укажите путь к бинарю") self._log("⚠ Укажите путь к бинарному файлу!")
return return
self._do_flash(target, bin_path) self._do_flash(target, bin_path)
@on(Button.Pressed, "#flash-btn-erase") @on(Button.Pressed, "#flash-btn-erase")
def _on_erase_pressed(self) -> None: def _on_erase_pressed(self) -> None:
if self._flashing: if not self._flashing:
return
self._do_erase() self._do_erase()
@on(Button.Pressed, "#flash-btn-quit")
def _on_quit_pressed(self) -> None:
if not self._flashing:
self.app.exit()
def action_go_back(self) -> None: def action_go_back(self) -> None:
if not self._flashing: if not self._flashing:
self.post_message(self.FlashDone(success=False)) self.post_message(self.FlashDone(success=False))
@ -137,6 +137,7 @@ class FlashScreen(Screen):
@work(exclusive=True, thread=False) @work(exclusive=True, thread=False)
async def _do_flash(self, target: FlashTarget, bin_path: Optional[Path]) -> None: async def _do_flash(self, target: FlashTarget, bin_path: Optional[Path]) -> None:
self._set_busy(True) self._set_busy(True)
self._show_progress(True)
self._log(f"▶ Прошивка: {target.value}") self._log(f"▶ Прошивка: {target.value}")
ok = await self._flasher.flash( ok = await self._flasher.flash(
target=target, target=target,
@ -144,16 +145,23 @@ class FlashScreen(Screen):
progress_cb=self._on_progress, progress_cb=self._on_progress,
) )
self._set_busy(False) self._set_busy(False)
self._finish_progress(ok)
self._log("✅ Готово" if ok else "❌ Ошибка") self._log("✅ Готово" if ok else "❌ Ошибка")
self.post_message(self.FlashDone(success=ok)) self.post_message(self.FlashDone(success=ok, target=target))
@work(exclusive=True, thread=False) @work(exclusive=True, thread=False)
async def _do_erase(self) -> None: async def _do_erase(self) -> None:
self._set_busy(True) self._set_busy(True)
self._log("⚠ Chip erase (~30 с)...") self._show_progress(True)
self._log("⚠ Очистка флеш памяти (~30 с)...")
ok = await self._flasher.erase_chip(progress_cb=self._on_progress) ok = await self._flasher.erase_chip(progress_cb=self._on_progress)
self._set_busy(False) self._set_busy(False)
self._log("✅ Chip erase завершён" if ok else "❌ Chip erase: ошибка") self._finish_progress(ok)
self._log(
"✅ Очистка флеш памяти завершена"
if ok
else "❌ Очистка флеш памяти: ошибка"
)
# ── Вспомогательные ─────────────────────────────────────────────────────── # ── Вспомогательные ───────────────────────────────────────────────────────
@ -177,18 +185,29 @@ class FlashScreen(Screen):
return None, None return None, None
async def _on_progress(self, progress: FlashProgress) -> None: async def _on_progress(self, progress: FlashProgress) -> None:
self.query_one("#flash-progress-bar", ProgressBar).update( bar = self.query_one("#flash-progress-bar", ProgressBar)
progress=progress.percent bar.update(total=100, progress=progress.percent)
)
self.query_one("#flash-progress-label", Label).update(
f"{progress.phase} {progress.percent}%"
)
self._log(progress.message) self._log(progress.message)
def _show_progress(self, visible: bool) -> None:
"""Показать/скрыть прогресс-бар. Скрыт в простое — без анимации."""
bar = self.query_one("#flash-progress-bar", ProgressBar)
if visible:
bar.update(total=100, progress=0)
bar.remove_class("hidden")
else:
bar.add_class("hidden")
def _finish_progress(self, success: bool) -> None:
"""Зафиксировать прогресс-бар на 100% с финальным статусом."""
bar = self.query_one("#flash-progress-bar", ProgressBar)
bar.update(total=100, progress=100)
def _set_busy(self, busy: bool) -> None: def _set_busy(self, busy: bool) -> None:
self._flashing = busy self._flashing = busy
self.query_one("#flash-btn-flash", Button).disabled = busy self.query_one("#flash-btn-flash", Button).disabled = busy
self.query_one("#flash-btn-erase", Button).disabled = busy self.query_one("#flash-btn-erase", Button).disabled = busy
self.query_one("#flash-btn-quit", Button).disabled = busy
def _log(self, msg: str) -> None: def _log(self, msg: str) -> None:
try: try:

View file

@ -0,0 +1,88 @@
"""
post_flash.py экран-промпт после успешной прошивки firmware_test.
Показывается только для FlashTarget.FIRMWARE_TEST (диагностическая прошивка)
после неё плата должна быть переведена в нормальный режим (BOOT_MOD GND)
чтобы попасть в DiagScreen. Без этого промпта TUI зацикливался: SDP всё ещё
виден WaitingScreen снова детектит SDP снова FlashScreen.
Для PRODUCTION/CUSTOM (либо когда прошивка не удалась) промпт не нужен
сразу WaitingScreen с тем же общим поведением автодетекта.
Три способа покинуть экран:
1. Оператор успел сменить BootMode и нажал "Готово" WaitingScreen
(далее автодетект сам поймает CDC, если плата уже перезагружена)
2. Оператор нажал "Выйти" app.exit()
3. Таймаут (по умолчанию 40 с) автоматически WaitingScreen
"""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import Button, Label, Static
from ..widgets import AppFrame
_AUTO_TIMEOUT_S = 40
_TICK_S = 1.0
class PostFlashScreen(Screen):
"""
Промпт смены BootMode после прошивки firmware_test.
Messages:
Done() оператор подтвердил или истёк таймаут пора в WaitingScreen
"""
class Done(Message):
"""Готово к переходу на WaitingScreen."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._remaining: int = _AUTO_TIMEOUT_S
self._timer: Timer | None = None
def compose(self) -> ComposeResult:
with AppFrame(id="post-flash-frame"):
yield Label("✅ firmware_test успешно записан", id="post-flash-title")
yield Static(
"Переведите плату в нормальный режим:\nBOOT_MOD_1 → GND → Reset",
id="post-flash-instruction",
)
yield Static("", id="post-flash-countdown")
with Horizontal(id="post-flash-btn-row"):
yield Button("✓ Готово", id="post-flash-btn-ok", variant="success")
yield Button("✕ Выйти", id="post-flash-btn-quit", variant="default")
def on_mount(self) -> None:
self._update_countdown()
self._timer = self.set_interval(_TICK_S, self._tick)
def on_unmount(self) -> None:
if self._timer is not None:
self._timer.stop()
def _tick(self) -> None:
self._remaining -= 1
self._update_countdown()
if self._remaining <= 0:
self.post_message(self.Done())
def _update_countdown(self) -> None:
self.query_one("#post-flash-countdown", Static).update(
f"Автопереход через: {self._remaining}с"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "post-flash-btn-ok":
self.post_message(self.Done())
elif event.button.id == "post-flash-btn-quit":
self.app.exit()

View file

@ -9,12 +9,14 @@ from __future__ import annotations
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.message import Message
from textual.screen import Screen from textual.screen import Screen
from textual.timer import Timer from textual.timer import Timer
from textual.widgets import Static from textual.widgets import Static
from textual.message import Message
from ..flasher import Flasher from ..flasher import Flasher
from ..models import AppMode from ..models import AppMode
from ..widgets import AppFrame
_DETECT_INTERVAL_S = 1.5 _DETECT_INTERVAL_S = 1.5
_SPIN_INTERVAL_S = 0.1 _SPIN_INTERVAL_S = 0.1
@ -43,6 +45,7 @@ class WaitingScreen(Screen):
self._spin_timer: Timer | None = None self._spin_timer: Timer | None = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with AppFrame(id="waiting-frame"):
yield Static("TFT Indicator Board\nService Tool", id="waiting-logo") yield Static("TFT Indicator Board\nService Tool", id="waiting-logo")
yield Static("Подключите плату к USB...", id="waiting-hint") yield Static("Подключите плату к USB...", id="waiting-hint")
yield Static(_SPINNER_FRAMES[0], id="waiting-spinner") yield Static(_SPINNER_FRAMES[0], id="waiting-spinner")
@ -52,10 +55,7 @@ class WaitingScreen(Screen):
self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin) self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin)
def on_unmount(self) -> None: def on_unmount(self) -> None:
if self._detect_timer: self._stop_timers()
self._detect_timer.stop()
if self._spin_timer:
self._spin_timer.stop()
# ── Internal ────────────────────────────────────────────────────────────── # ── Internal ──────────────────────────────────────────────────────────────

View file

@ -0,0 +1,5 @@
"""widgets — общие переиспользуемые виджеты TUI."""
from .app_frame import AppFrame
__all__ = ["AppFrame"]

View file

@ -0,0 +1,40 @@
"""
app_frame.py общий контейнер фиксированного размера для всех экранов.
Решает две задачи:
1. UX: визуальная рамка фиксированного размера (как в ratatui-приложениях,
напр. binsider), центрированная в терминале вне зависимости от размера окна.
2. Баг Textual 8.x: AssertionError при mouse drag, когда Screen выступает
content_widget напрямую (assert isinstance(content_widget.parent, Widget)
падает, потому что в этом сценарии родитель не является валидным Widget).
AppFrame как промежуточный контейнер между Screen и содержимым устраняет
этот сценарий content_widget при drag теперь всегда AppFrame или его
потомок, у которых .parent валиден.
Использование в экране::
def compose(self) -> ComposeResult:
with AppFrame():
yield Static("...")
yield Button("...")
"""
from __future__ import annotations
from textual.containers import Container
class AppFrame(Container):
"""Контейнер фиксированного размера, центрированный в Screen."""
DEFAULT_CSS = """
AppFrame {
width: 100%;
height: 100%;
max-width: 160;
max-height: 50;
border: heavy $primary;
background: $surface;
padding: 1 2;
}
"""

File diff suppressed because it is too large Load diff