Merge branch 'feature-tui-python' into dev

This commit is contained in:
Dmitry Akimov 2026-07-01 16:58:19 +03:00
commit 477c0d6205
54 changed files with 5869 additions and 110 deletions

View file

@ -13,6 +13,8 @@ BOOTROM_VID=1fc9
BOOTROM_PID=0130
FLASHLOADER_VID=15a2
FLASHLOADER_PID=0073
SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad
# --- Paths ---
# BUILD_DIR и TOOLS_DIR задаются абсолютно в корневом justfile
@ -32,9 +34,10 @@ GDB_EXECUTABLE=arm-none-eabi-gdb
PYOCD_TARGET=mimxrt1050_quadspi
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
# --- HIL (аппаратный стенд) ---
HIL_PYOCD_FREQUENCY=1000000
HIL_BUILD_DIR=build/target-debug
@ -56,3 +59,6 @@ HIL_M5_TIMEOUT=3.0
HIL_USB_CDC_PORT=/dev/cu.usbmodem11301
HIL_USB_CDC_BAUD=115200
HIL_USB_CDC_TIMEOUT=5.0
# Тип сборки firmware_test для TUI (Debug | Release)
FIRMWARE_BUILD_TYPE=Release

1
.gitignore vendored
View file

@ -59,6 +59,7 @@ build*
Testing/
!docs/testing
!build.just
service_tui.log
# Файлы настройки среды разработки
.project

View file

@ -5,8 +5,10 @@
#include "bsp/sd.h"
#include "fsl_sd.h"
#include "fsl_usdhc.h" /* USDHC_Reset — аппаратный сброс FIFO/state machine */
#include "sdmmc_config.h" /* BOARD_SD_Config, BOARD_SDMMC_SD_HOST_BASEADDR */
#include <string.h> /* memset */
/* ---------------------------------------------------------------------------
* Глобальный дескриптор карты нужен SDK-стеку (передаётся по указателю
* в BOARD_SD_Config и sd_disk_initialize через g_sd).
@ -51,6 +53,33 @@ bsp_status_t bsp_sd_init(void)
return BSP_OK;
}
/*
* Аппаратный сброс USDHC FIFO + command/data state machine ПЕРЕД
* повторной инициализацией. Без этого non-blocking host driver SDK
* (fsl_sdmmc_host.c) может остаться в состоянии "ожидание завершения
* предыдущей транзакции" после SD_HostDeinit() на прошлом прогоне —
* физическая транзакция уже умерла вместе с deinit, но внутренний
* флаг ожидания interrupt остаётся выставленным, и следующий f_mount()
* блокируется навсегда в ожидании события, которое никогда не придёт.
*
* USDHC_Reset с маской kUSDHC_ResetAll сбрасывает контроллер на
* регистровом уровне, не полагаясь на состояние, оставленное
* предыдущей сессией. Безопасно вызывать даже при первом запуске
* базовый адрес уже доступен через BOARD_SDMMC_SD_HOST_BASEADDR
* (clock на этот момент должен быть включён, см. ниже).
*/
CLOCK_EnableClock(kCLOCK_Usdhc1); /* тактирование нужно ДО сброса регистров */
USDHC_Reset(BOARD_SDMMC_SD_HOST_BASEADDR, kUSDHC_ResetAll, 100U);
/*
* Обнулить g_sd целиком перед повторной конфигурацией. BOARD_SD_Config()
* перезаписывает только часть полей указатели на callback-структуры
* non-blocking host driver и внутренние DMA-дескрипторы могут остаться
* от предыдущей сессии, если их явно не сбросить.
*/
(void) memset(&g_sd, 0, sizeof(g_sd));
g_s_host_configured = false; /* форсируем повторный BOARD_SD_Config ниже */
ensure_host_configured(); /* только BOARD_SD_Config — заполняет g_sd */
/*
@ -72,6 +101,14 @@ bsp_status_t bsp_sd_deinit(void)
SD_HostDeinit(&g_sd);
SD_SetCardPower(&g_sd, false);
/*
* Дополнительный аппаратный сброс сразу после deinit гарантирует,
* что FIFO и state machine USDHC не останутся в промежуточном
* состоянии независимо от того, что делает (или не делает)
* SD_HostDeinit() из SDK на уровне регистров.
*/
USDHC_Reset(BOARD_SDMMC_SD_HOST_BASEADDR, kUSDHC_ResetAll, 100U);
g_s_initialized = false;
g_s_host_configured = false;
return BSP_OK;

View file

@ -19,8 +19,8 @@
#define USB_DEVICE_DEMO_BCD_VERSION (0x0101U)
/* ---- VID / PID --------------------------------------------------------- */
#define USB_DEVICE_VID (0x1996U) /* TODO: заменить на свой */
#define USB_DEVICE_PID (0x00ADU) /* TODO: заменить на свой */
#define USB_DEVICE_VID (0x1996U)
#define USB_DEVICE_PID (0x00ADU)
/* ---- CDC коды классов -------------------------------------------------- */
#define CDC_COMM_CLASS (0x02U)

View file

@ -194,9 +194,14 @@ flowchart LR
│ │ ├── flash_usb.py ← USB SDP: sdphost + blhost
│ │ ├── flash_swd.py ← SWD: FCB + HAB → pyOCD Flash
│ │ ├── hab/ ← HAB yaml-конфиги (nxpimage)
│ │ ├── dcd/ ← w25q128_fdcb.bin, ivt_flashloader.bin
│ │ ├── dcd/ ← w25q128_fdcb.bin, w25q512_fdcb.bin,
│ │ │ dcd.bin, ivt_flashloader.bin
│ │ └── uv.lock
│ │
│ ├── production/ ← service-tui: TUI сервисного инженера (Textual)
│ │ прошивка/диагностика готовых плат, см.
│ │ tools/production/README.md + DEV_ARCH.md
│ │
│ └── hil/ ← HIL pytest-окружение
│ ├── conftest.py ← фикстуры: m5, loaded_<n>, uart_<n>
│ ├── pyocd_utils.py ← FLEXRAM init, ELF loader, run_from_vectors

View file

@ -72,7 +72,26 @@ flowchart TD
```
ROM-загрузчик сам конфигурирует FlexSPI через DCD из HAB-образа, поэтому FCB
в образе не нужен — его пишет Flashloader отдельно.
в образе не нужен — его пишет Flashloader отдельно. Это верно для W25Q128
(текущая плата) — auto-config Flashloader для неё проверен на практике.
Для плат с другой памятью (W25Q256/512, 4-байтная адресация) надёжность
auto-config не подтверждена — см. 1.5.
### 1.5 Нестандартная память (W25Q256/512) и сторонние бинарники
`service-tui` (`tools/production/`) умеет прошивать бинарники, собранные не
в этом репозитории (например, старые платы с W25Q512), тем же способом
(USB SDP), но с двумя отличиями от штатного пути:
- HAB-образ (IVT + опционально DCD) собирается из **сырого** бинарника на
лету через `nxpimage`, а не заранее через `just build::hab-*`
- FCB пишется **явно** (`flash_usb.py --fcb-path tools/host/dcd/w25qXXX_fdcb.bin`,
буквальный `write-memory` вместо `configure-memory 0xF000000F`) — auto-config
для 4-байтной адресации не проверялся, решили на него не полагаться
Подробности конвейера — в `tools/production/DEV_ARCH.md`, §8. Штатный путь
(`--firmware`, три сборки этого репозитория) не меняется и по-прежнему
использует auto-config Flashloader, как описано в 1.4.
---
@ -142,15 +161,19 @@ FCB-бинарник (`w25q128_fdcb.bin`) генерируется в NXP Secure
## Сравнение способов
| | USB SDP | SWD |
| ------------------------- | -------------------------------- | -------------------- |
| ------------------------- | ----------------------- | -------------------- |
| Переключение BOOT_MODE | Нужно | Не нужно |
| Power cycle после записи | Не нужен | **Обязателен** |
| FCB в образе | Не нужен (Flashloader пишет сам) | **Обязателен** |
| FCB в образе | Не нужен для W25Q128* | **Обязателен** |
| Скорость записи | ~50100 kB/s | ~810 kB/s |
| Совместимость с отладкой | Раздельно | MCU-Link монопольный |
| Производственный сценарий | ✓ | — |
| Итеративная разработка | Неудобно (смена режима) | ✓ |
\* Flashloader пишет FCB сам через auto-config — проверено для W25Q128.
Для сторонних бинарников с другой памятью `service-tui` пишет FCB явно,
см. 1.5.
---
## Диагностика

View file

@ -1,8 +1,17 @@
# firmware/test/CMakeLists.txt Тестовая прошивка — входной контроль платы на
# производстве
cmake_minimum_required(VERSION 3.20)
project(
firmware_test
VERSION 0.1.1
LANGUAGES C ASM)
set(TARGET_NAME firmware_test)
# Генерация version.h из шаблона
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/src/version.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/version.h" @ONLY)
add_subdirectory(fatfs)
add_executable(
@ -25,6 +34,8 @@ add_executable(
target_include_directories(firmware_test PRIVATE src/)
target_include_directories(firmware_test
PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
# __STARTUP_INITIALIZE_RAMFUNCTION - очистка секции .ram_function и копирование
# туда данных из __ram_function_flash_start; __STARTUP_INITIALIZE_NONCACHEDATA -
# инициализация некешируемое секции нулями
@ -73,7 +84,7 @@ target_link_options(
-Wl,-Map=${CMAKE_BINARY_DIR}/firmware_test.map
-Wl,--defsym=__stack_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
${CMAKE_BINARY_DIR})

View file

@ -331,6 +331,12 @@ static void handle_cmd(const char *p_line)
return;
}
if (strcmp(cmd_name, "get_version") == 0)
{
protocol_send_version_response();
return;
}
if (strcmp(cmd_name, "list_tests") == 0)
{
test_runner_send_list();

View file

@ -158,3 +158,12 @@ void protocol_send_uid_response(const uint8_t *p_uid)
(unsigned int) p_uid[6U], (unsigned int) p_uid[7U]);
cli_send(buf);
}
void protocol_send_version_response(void)
{
char buf[PROTO_BUF_SIZE];
(void) snprintf(buf, sizeof(buf),
"{\"type\":\"version_response\","
"\"fw\":\"" FIRMWARE_TEST_VERSION "\"}\n");
cli_send(buf);
}

View file

@ -19,12 +19,14 @@
#define PROTOCOL_H_
#include "test_module.h"
#include "version.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/** @brief Строка версии прошивки, вставляемая в session_start. */
#define FIRMWARE_TEST_VERSION "0.1.4"
#define FIRMWARE_TEST_VERSION FIRMWARE_TEST_VERSION_STR
/** @brief Таймаут подтверждения по умолчанию, мс. */
#define PROTOCOL_CONFIRM_TIMEOUT_MS 30000U
@ -100,4 +102,11 @@ void protocol_send_error(const char *p_code);
*/
void protocol_send_uid_response(const uint8_t *p_uid);
/**
* @brief Отправить ответ на команду get_version.
*
* Формат: {"type":"version_response","fw":"X.Y.Z"}
*/
void protocol_send_version_response(void);
#endif /* PROTOCOL_H_ */

View file

@ -143,14 +143,14 @@ static void buttons_test_init(void)
*/
static test_result_t buttons_test_run(void)
{
if (!wait_button_press(BSP_BUTTON_1, "btn1_press", "Press Test_But_1", BTN_PRESS_TIMEOUT_MS))
if (!wait_button_press(BSP_BUTTON_1, "btn1_press", "Нажмите кнопку 1", BTN_PRESS_TIMEOUT_MS))
{
return make_skip("btn1_press timeout");
return make_skip("Кнопка 1 - таймаут");
}
if (!wait_button_press(BSP_BUTTON_2, "btn2_press", "Press Test_But_2", BTN_PRESS_TIMEOUT_MS))
if (!wait_button_press(BSP_BUTTON_2, "btn2_press", "Нажмите кнопку 2", BTN_PRESS_TIMEOUT_MS))
{
return make_skip("btn2_press timeout");
return make_skip("Кнопка 2 - таймаут");
}
return (test_result_t){
@ -165,7 +165,7 @@ static test_result_t buttons_test_run(void)
/** @brief Дескриптор тест-модуля кнопок для реестра test_runner. */
const test_module_t K_TEST_BUTTONS = {
.id = "buttons",
.name = "Test Buttons",
.name = "Тактовые кнопки",
.critical = false,
.requires_hil = false,
.pre_confirm_prompt = NULL,

View file

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

View file

@ -222,11 +222,11 @@ static bool step_rot_apply_and_confirm(bsp_display_rotation_t rotation, const ch
static bool step_rotation(test_result_t *p_out)
{
bool ok = step_rot_apply_and_confirm(BSP_DISPLAY_ROTATE_0, "display_rot0",
"Screen: left RED, right BLUE?", p_out);
"Дисплей: слева - красный, справа - синий?", p_out);
if (ok)
{
ok = step_rot_apply_and_confirm(BSP_DISPLAY_FLIP_HORIZONTAL, "display_rot_base",
"Left RED and right BLUE swapped sides?", p_out);
"Дисплей: слева - синий, справа - красный?", p_out);
}
/* Восстановить ROTATE_0 в любом исходе */
@ -264,19 +264,19 @@ static test_result_t display_test_run(void)
test_result_t fail_result = { .status = TEST_STATUS_FAIL, .duration_ms = 0U, .detail = { 0 } };
/* Этап 1: цвет */
if (!step_color(DISPLAY_COLOR_RED, "display_red", "Screen is solid red?", &fail_result))
if (!step_color(DISPLAY_COLOR_RED, "display_red", "Дисплей залит красным?", &fail_result))
{
return fail_result;
}
if (!step_color(DISPLAY_COLOR_GREEN, "display_green", "Screen is solid green?", &fail_result))
if (!step_color(DISPLAY_COLOR_GREEN, "display_green", "Дисплей залит зеленым?", &fail_result))
{
return fail_result;
}
if (!step_color(DISPLAY_COLOR_BLUE, "display_blue", "Screen is solid blue?", &fail_result))
if (!step_color(DISPLAY_COLOR_BLUE, "display_blue", "Дисплей залит синим?", &fail_result))
{
return fail_result;
}
if (!step_color(DISPLAY_COLOR_WHITE, "display_white", "Screen is solid white?", &fail_result))
if (!step_color(DISPLAY_COLOR_WHITE, "display_white", "Дисплей залит белым?", &fail_result))
{
return fail_result;
}
@ -308,7 +308,7 @@ static void display_test_deinit(void)
const test_module_t K_TEST_DISPLAY = {
.id = "display",
.name = "TFT Display RGB888",
.name = "TFT дисплей",
.critical = false,
.requires_hil = false,
.pre_confirm_prompt = NULL,

View file

@ -190,7 +190,7 @@ static test_result_t test_mqs_run(void)
*/
const confirm_params_t K_PARAMS = {
.id = "mqs_tone",
.prompt = "Do you hear a tone?",
.prompt = "Звук слышен?",
.timeout_ms = 15000U,
};
@ -200,7 +200,7 @@ static test_result_t test_mqs_run(void)
{
return (test_result_t){
.status = TEST_STATUS_FAIL,
.detail = "operator: no sound",
.detail = "Звук не был услышан",
};
}
@ -220,7 +220,7 @@ static void test_mqs_deinit(void)
const test_module_t K_TEST_MQS = {
.id = "mqs",
.name = "MQS Audio Out",
.name = "Аудио-выход",
.critical = false,
.requires_hil = false,
.pre_confirm_prompt = NULL,

View file

@ -182,7 +182,7 @@ static test_result_t opto_run(void)
/** @brief Дескриптор для регистрации в test_runner. */
const test_module_t K_TEST_OPTO = {
.id = "opto",
.name = "Opto Inputs",
.name = "Оптопары",
.critical = false,
.requires_hil = true,
.pre_confirm_prompt = NULL,

View file

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

View file

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

View file

@ -343,10 +343,10 @@ static void usd_deinit(void)
const test_module_t K_TEST_USD = {
.id = "usd",
.name = "microSD (SDIO)",
.name = "microSD карта",
.critical = false,
.requires_hil = false,
.pre_confirm_prompt = "Insert microSD card and press OK",
.pre_confirm_prompt = "Карта вставлена в слот?",
.init = usd_init,
.run = usd_run,
.deinit = usd_deinit,

View file

@ -0,0 +1,25 @@
/**
* @file version.h
* @brief Версия firmware_test генерируется CMake из CMakeLists.txt.
*
* НЕ редактировать вручную. Версию менять в firmware/test/CMakeLists.txt:
* project(firmware_test VERSION X.Y.Z)
*/
#ifndef VERSION_H_
#define VERSION_H_
/** @brief Мажорная версия. */
#define FIRMWARE_TEST_VERSION_MAJOR @firmware_test_VERSION_MAJOR@
/** @brief Минорная версия. */
#define FIRMWARE_TEST_VERSION_MINOR @firmware_test_VERSION_MINOR@
/** @brief Патч-версия. */
#define FIRMWARE_TEST_VERSION_PATCH @firmware_test_VERSION_PATCH@
/** @brief Версия строкой для протокола: "X.Y.Z". */
#define FIRMWARE_TEST_VERSION_STR \
"@firmware_test_VERSION_MAJOR@.@firmware_test_VERSION_MINOR@.@firmware_test_VERSION_PATCH@"
#endif /* VERSION_H_ */

View file

@ -1,6 +1,6 @@
# =============================================================================
# scripts/build.just — сборка, тесты, HAB-образы
# Выполняется ТОЛЬКО внутри devcontainer (из терминала VSCode или tasks).
# Выполняется внутри devcontainer
#
# Рабочая директория — корень репозитория.
# =============================================================================

View file

@ -11,6 +11,7 @@ _venv := if _uname =~ "MINGW|MSYS|CYGWIN" { ".venv-host-win" } else { ".venv-hos
JUST_MIN := "1.36.0"
UV_MIN := "0.4.0"
DOCKER_MIN := "24.0.0"
PLATFORM := os()
set working-directory := '..'
@ -31,14 +32,6 @@ check-deps:
fail() { echo -e " ${RED}❌ ${1}${RESET}"; ERRORS=$((ERRORS+1)); }
warn() { echo -e " ${YELLOW}⚠️ ${1}${RESET}"; WARNINGS=$((WARNINGS+1)); }
_uname="$(uname -s)"
case "${_uname}" in
Linux*) PLATFORM="linux" ;;
Darwin*) PLATFORM="macos" ;;
MINGW*|MSYS*|CYGWIN*) PLATFORM="windows" ;;
*) PLATFORM="unknown" ;;
esac
semver_ge() {
local a="$1" b="$2"
local a1 a2 a3 b1 b2 b3
@ -56,7 +49,7 @@ check-deps:
install_hint() {
local tool=$1
case "${PLATFORM}" in
case "{{ PLATFORM }}" in
linux)
case "$tool" in
just) echo "uv tool install 'rust-just=={{ JUST_MIN }}'" ;;
@ -100,7 +93,7 @@ check-deps:
}
echo ""
echo -e "${BOLD} Platform: ${PLATFORM}${RESET}"
echo -e "${BOLD} Platform: {{ PLATFORM }}${RESET}"
echo ""
echo -e "${BOLD} Checking host dependencies...${RESET}"
echo ""
@ -109,7 +102,7 @@ check-deps:
check_tool "uv" "uv" "{{ UV_MIN }}"
check_tool "docker" "docker" "{{ DOCKER_MIN }}"
if [[ "${PLATFORM}" == "linux" ]]; then
if [[ "{{ PLATFORM }}" == "linux" ]]; then
echo ""
echo -e "${BOLD} Checking udev rules (Linux only)...${RESET}"
echo ""
@ -182,22 +175,16 @@ setup-tools:
bootstrap:
#!/usr/bin/env bash
set -euo pipefail
_uname="$(uname -s)"
case "${_uname}" in
Linux*) PLATFORM="linux" ;;
Darwin*) PLATFORM="macos" ;;
MINGW*|MSYS*|CYGWIN*) PLATFORM="windows" ;;
*) PLATFORM="unknown" ;;
esac
echo ""
echo " === Bootstrap: step 1/3 -- check-deps ==="
just host::check-deps
if [[ "${PLATFORM}" == "linux" ]]; then
if [[ "{{ PLATFORM }}" == "linux" ]]; then
echo " === Bootstrap: step 2/3 -- setup-udev ==="
just host::setup-udev
just host::setup-m5-udev
else
echo " step 2/3 -- setup-udev skipped (${PLATFORM})"
echo " step 2/3 -- setup-udev skipped ({{ PLATFORM }})"
fi
echo " === Bootstrap: step 3/3 -- setup-tools ==="
just host::setup-tools
@ -255,20 +242,23 @@ flash-ram project type="debug":
[doc('Прошить firmware_test Debug через USB SDP')]
[group('flash-sdp')]
flash-test-debug:
@just host::flash firmware_test debug
flash-test-debug: (flash "firmware_test" "debug")
[doc('Прошить firmware_test Release через USB SDP')]
[group('flash-sdp')]
flash-test-release:
@just host::flash firmware_test release
flash-test-release: (flash "firmware_test" "release")
[confirm("Flash bootloader + app (Release)?")]
[doc('Прошить bootloader + tft_app Release через USB SDP (производство)')]
[group('flash-sdp')]
flash-production:
@just host::flash bootloader release
@just host::flash app release
flash-production: (flash "bootloader" "release") (flash "app" "release")
[confirm("Стереть всю Flash (W25Q128) через USB SDP? Все прошивки будут удалены.")]
[doc('Стереть всю Flash (W25Q128) через USB SDP (chip erase, ~30 с)')]
[group('flash-sdp')]
flash-sdp-erase:
cd "{{ TOOLS_DIR }}" && UV_PROJECT_ENVIRONMENT={{ _venv }} uv run python flash_usb.py \
--erase-chip
# =============================================================================
# ГРУППА: flash-swd — прошивка через SWD (MCU-Link, без смены BOOT_MODE)
@ -319,7 +309,7 @@ flash-swd-erase:
uv run --directory {{ HIL_DIR }} pyocd erase \
--target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \
--frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \
--chip
--chip -O resume_on_disconnect=False
# =============================================================================
# ГРУППА: hil — запуск HIL-тестов на реальном железе
@ -543,3 +533,36 @@ incoming: flash-test-release
[group('pipeline')]
production: flash-production
@echo " ✅ Production firmware flashed"
# =============================================================================
# ГРУППА: service — TUI сервисного инженера
# =============================================================================
PRODUCTION_DIR := justfile_directory() / 'tools/production'
[doc('Установить/обновить зависимости TUI сервисного инженера')]
[group('service')]
service-setup:
#!/usr/bin/env bash
set -euo pipefail
echo " 📦 Syncing tools/production deps..."
cd "{{ PRODUCTION_DIR }}" && uv sync
echo " ✅ tools/production deps installed"
[doc('Запустить TUI сервисного инженера')]
[group('service')]
service-tui:
uv run --directory {{ PRODUCTION_DIR }} python main.py
[doc('Собрать standalone-бинарь TUI (PyInstaller → dist/service_tui)')]
[group('service')]
service-build:
#!/usr/bin/env bash
set -euo pipefail
echo " 🔨 Building standalone service-tui..."
cd "{{ PRODUCTION_DIR }}" && uv run pyinstaller \
--onefile \
--name service_tui \
--add-data "../shared:shared" \
main.py
echo " ✅ dist/service_tui ready"

View file

@ -12,21 +12,28 @@ Python-окружение на базе [uv](https://docs.astral.sh/uv/) для
```bash
tools/host/
├── flash_usb.py — прошивка через USB ROM: sdphost → Flashloader → Flash
│ (+ --bin-path/--fcb-path — сторонние образы с явным
│ FCB, вызывается из service-tui, см. tools/production/)
├── flash_swd.py — прошивка через SWD: FCB + HAB → pyOCD → Flash
├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип)
├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип;
│ service-tui генерирует такие же временно, на лету —
│ см. tools/production/DEV_ARCH.md, §8)
├── dcd/
│ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP)
│ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P)
│ ├── w25q128_fdcb.bin — FCB для W25Q128 Quad SPI ← используется
│ ├── w25q64_fdcb.bin — FCB для W25Q64 Quad SPI
│ └── w25q512_fdcb.bin — FCB для W25Q512 Quad SPI
│ └── w25q512_fdcb.bin — FCB для W25Q512 Quad SPI ← используется
├── ../../docs/mimxrt1052/HAB_GUIDE.md — подробно про HAB-образы и процесс подписи
├── pyproject.toml
└── uv.lock
```
> Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся
> в репозитории — пересоздавать не нужно.
> в репозитории — пересоздавать не нужно. `w25q128`/`w25q512` — единственные
> два варианта в реальном использовании (64 и 256 сведены к ним же, см.
> `tools/production/DEV_ARCH.md`, §8.2); `w25q64_fdcb.bin` пока не подключён
> нигде — оставлен про запас.
---
@ -35,6 +42,10 @@ tools/host/
Подробное описание обоих способов прошивки — в `docs/HOW_TO_FLASH.md`.
Сравнительная таблица, карта Flash, диагностика — там же.
Прошивка сторонних/легаси бинарников с нестандартной памятью (явный FCB,
без auto-config) — через `service-tui` (`tools/production/`), не напрямую
через `flash_usb.py` из терминала. Детали конвейера — `tools/production/DEV_ARCH.md`, §8.
---
## Быстрый старт

View file

@ -7,17 +7,25 @@ flash_usb.py — прошивка MIMXRT1052 через USB (BootROM SDP → Fla
python3 flash_usb.py --firmware app --build-type Release
python3 flash_usb.py --firmware bootloader --build-type Release
python3 flash_usb.py --firmware firmware_test --build-type Debug --ram-only
python3 flash_usb.py --bin-path /path/to/custom.bin
python3 flash_usb.py --erase-chip
Шаги:
Шаги (прошивка):
1. Устройство в SDP режиме (VID:PID 1FC9:0130)
sdphost загружает ivt_flashloader.bin в RAM
sdphost прыгает на flashloader
2. Flashloader запущен (VID:PID 15A2:0073)
blhost конфигурирует FlexSPI NOR (пишет FCB)
blhost стирает нужный регион Flash
blhost пишет HAB образ начиная с 0x60002000
blhost пишет HAB образ начиная с 0x60001000
blhost reset
Шаги (chip erase):
1. Загрузка Flashloader (аналогично прошивке)
2. blhost configure-memory (инициализация FlexSPI контроллера)
3. blhost flash-erase-all (полная очистка W25Q, ~30 с)
4. blhost reset
Конфигурация:
Переменные окружения (задаются в .env, экспортируются через just):
BOOTROM_VID VID BootROM SDP (default: 1fc9)
@ -39,23 +47,21 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
REPO_ROOT = SCRIPT_DIR.parent.parent
FLASHLOADER = SCRIPT_DIR / "dcd" / "ivt_flashloader.bin"
# BUILD_DIR: берём из окружения (just экспортирует из .env как абсолютный путь),
# fallback — рассчитываем от расположения скрипта
BUILD_DIR = Path(os.environ.get("BUILD_DIR", str(REPO_ROOT / "build")))
# ─── USB VID:PID ──────────────────────────────────────────────────────────────
# ─── USB VID:PID — из окружения (.env → just set export → uv run) ─────────────
def _usb(vid_key: str, vid_default: str, pid_key: str, pid_default: str) -> str:
vid = os.environ.get(vid_key, vid_default).strip().upper().lstrip("0X")
pid = os.environ.get(pid_key, pid_default).strip().upper().lstrip("0X")
return f"0x{vid},0x{pid}"
SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130") # BootROM SDP
BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073") # Flashloader
SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130")
BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073")
# ─── Аппаратные константы (часть логики прошивки, не конфигурация) ────────────
# ─── Аппаратные константы ─────────────────────────────────────────────────────
# FlexSPI NOR config option word: 0xC0000007
# bits[31:28]=0xC — tag (QuadSPI NOR)
# bits[3:0]=0x7 — option size
@ -70,6 +76,10 @@ FLEXSPI_FCB_VALUE = "0xF000000F"
FLASH_BASE = 0x60000000
HAB_OFFSET = 0x1000 # IVT offset: write address = FLASH_BASE + HAB_OFFSET
ERASE_ALL_TIMEOUT_MS = "200000" # W25Q512 стирается заметно дольше W25Q128
# ─── Helpers ──────────────────────────────────────────────────────────────────
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
print(f" $ {' '.join(cmd)}")
@ -87,7 +97,7 @@ def step(msg: str) -> None:
def wait_for_flashloader(timeout: int = 10) -> bool:
"""Ждём пока Flashloader поднимется"""
"""Ждём пока Flashloader поднимется (опрашиваем blhost раз в секунду)."""
print(f"\n Ожидание Flashloader (до {timeout}с)...", end="", flush=True)
for i in range(timeout):
time.sleep(1)
@ -104,7 +114,7 @@ def wait_for_flashloader(timeout: int = 10) -> bool:
def load_flashloader() -> None:
"""Загружает Flashloader через SDP если ещё не запущен"""
"""Загружает Flashloader через SDP если ещё не запущен."""
result = subprocess.run(
["blhost", "-u", BLHOST_USB, "-j", "--", "get-property", "1", "0"],
capture_output=True,
@ -114,9 +124,12 @@ def load_flashloader() -> None:
return
if not FLASHLOADER.exists():
print(f"[ERROR] Не найден: {FLASHLOADER}")
print(" Скачай ivt_flashloader.bin из MCUXpresso Secure Provisioning Tool")
print(" и положи в tools/host/dcd/ivt_flashloader.bin")
print(f"[ERROR] Не найден: {FLASHLOADER}", file=sys.stderr)
print(
" Скачай ivt_flashloader.bin из MCUXpresso Secure Provisioning Tool\n"
" и положи в tools/host/dcd/ivt_flashloader.bin",
file=sys.stderr,
)
sys.exit(1)
step("Загрузка Flashloader через SDP (1FC9:0130)")
@ -135,12 +148,15 @@ def load_flashloader() -> None:
run(["sdphost", "-u", SDP_USB, "-j", "--", "jump-address", "0x20001C00"])
if not wait_for_flashloader():
print("[ERROR] Flashloader не ответил. Проверь BOOT_MOD пины и подключение.")
print(
"[ERROR] Flashloader не ответил. Проверь BOOT_MOD пины и подключение.",
file=sys.stderr,
)
sys.exit(1)
def configure_flexspi() -> None:
"""Инициализирует FlexSPI NOR контроллер через Flashloader"""
"""Инициализирует FlexSPI NOR контроллер через Flashloader."""
step("Конфигурация FlexSPI NOR (инициализация контроллера)")
run(
[
@ -169,9 +185,9 @@ def configure_flexspi() -> None:
def write_fcb() -> None:
"""Записывает Flash Configuration Block в 0x60000000
"""Записывает Flash Configuration Block в 0x60000000.
Отдельный шаг после erase! Flashloader генерирует FCB из параметров FlexSPI
Отдельный шаг после erase: Flashloader генерирует FCB из параметров FlexSPI
и пишет его по адресу 0x60000000. Без FCB BootROM не знает как читать Flash.
Option word 0xF000000F: tag=0xF Write FCB command.
"""
@ -202,10 +218,45 @@ def write_fcb() -> None:
)
def flash(hab_bin: Path, ram_only: bool = False) -> None:
def write_fcb_explicit(fcb_path: Path) -> None:
"""Записывает буквальный FCB-блоб (512 байт) в Flash[0x60000000].
В отличие от write_fcb() (magic option word 0xF000000F auto-config
Flashloader, надёжно проверен только для W25Q128), здесь FCB пишется
байт-в-байт через write-memory. Нужен для custom-бинарей: nxpimage
всегда собирает "чистый" app-образ без FCB (см. hab_*.yaml FCB туда
не входит), поэтому его нужно подставлять явно под конкретный чип
tools/host/dcd/w25q128_fdcb.bin или tools/host/dcd/w25q512_fdcb.bin.
"""
if not fcb_path.exists():
print(f"[ERROR] FCB-файл не найден: {fcb_path}", file=sys.stderr)
sys.exit(1)
step(f"Запись явного FCB ({fcb_path.name}) в Flash[0x60000000]")
run(
[
"blhost",
"-u",
BLHOST_USB,
"--",
"write-memory",
f"0x{FLASH_BASE:08X}",
str(fcb_path),
"0",
]
)
# ─── Основные операции ────────────────────────────────────────────────────────
def flash(hab_bin: Path, ram_only: bool = False, fcb_path: Path | None = None) -> None:
"""Прошить HAB-образ в Flash или загрузить в RAM."""
if not hab_bin.exists():
print(f"[ERROR] Файл не найден: {hab_bin}")
print(" Сначала собери образ: uv run nxpimage hab export -c ...")
print(f"[ERROR] Файл не найден: {hab_bin}", file=sys.stderr)
print(
" Сначала собери образ: uv run nxpimage hab export -c ...", file=sys.stderr
)
sys.exit(1)
if ram_only:
@ -260,6 +311,9 @@ def flash(hab_bin: Path, ram_only: bool = False) -> None:
]
)
if fcb_path is not None:
write_fcb_explicit(fcb_path)
else:
write_fcb()
run(
@ -277,39 +331,137 @@ def flash(hab_bin: Path, ram_only: bool = False) -> None:
step("Reset")
run(["blhost", "-u", BLHOST_USB, "--", "reset"])
print("\n ✓ Прошивка завершена успешно")
print("\n ✅ Прошивка завершена успешно")
def erase_chip() -> None:
"""Полная очистка Flash (chip erase) через Flashloader.
Использует blhost flash-erase-all (memory ID 9 = FlexSPI NOR).
Время операции: ~30 с для W25Q128.
После erase FCB также стёрт BootROM не сможет загрузить прошивку
до следующей прошивки (flash_usb.py запишет FCB автоматически).
"""
load_flashloader()
configure_flexspi()
step("Полная очистка Flash (chip erase, ~30 с)")
print(" ⚠️ После chip erase BootROM не сможет загрузить прошивку.")
print(" ⚠️ Используй flash_usb.py для восстановления.\n")
run(
[
"blhost",
"-t",
ERASE_ALL_TIMEOUT_MS,
"-u",
BLHOST_USB,
"--",
"flash-erase-all",
FLEXSPI_MEMORY_ID,
]
)
step("Reset")
run(["blhost", "-u", BLHOST_USB, "--", "reset"])
print("\n ✅ Chip erase завершён")
# ─── CLI ──────────────────────────────────────────────────────────────────────
def main() -> None:
print(f"DEBUG SDP_USB = {repr(SDP_USB)}")
print(f"DEBUG BLHOST_USB = {repr(BLHOST_USB)}")
print(f"DEBUG BUILD_DIR = {repr(BUILD_DIR)}")
parser = argparse.ArgumentParser(description="Прошивка MIMXRT1052 через USB")
parser = argparse.ArgumentParser(
description="Прошивка/очистка MIMXRT1052 через USB SDP",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--fcb-path",
type=Path,
metavar="PATH",
default=None,
help=(
"Явный FCB-блоб (512 байт) для записи в 0x60000000 вместо "
"auto-config Flashloader. Имеет смысл только с --bin-path."
),
)
# Группа: что прошивать (взаимоисключающие варианты)
target_group = parser.add_mutually_exclusive_group()
target_group.add_argument(
"--firmware",
required=True,
choices=["firmware_test", "bootloader", "app"],
help="Имя прошивки",
help="Стандартная прошивка из BUILD_DIR (требует --build-type)",
)
target_group.add_argument(
"--bin-path",
type=Path,
metavar="PATH",
help="Путь к произвольному HAB-бинарю (.bin) для прошивки",
)
target_group.add_argument(
"--erase-chip",
action="store_true",
help="Полная очистка Flash (chip erase, ~30 с). Прошивка не выполняется.",
)
parser.add_argument(
"--build-type",
choices=["Debug", "Release"],
default="Release",
help="Тип сборки (только для --firmware, default: Release)",
)
parser.add_argument(
"--build-type", required=True, choices=["Debug", "Release"], help="Тип сборки"
)
parser.add_argument(
"--ram-only", action="store_true", help="Загрузить в RAM без записи во Flash"
"--ram-only",
action="store_true",
help="Загрузить в RAM без записи во Flash (только для --firmware / --bin-path)",
)
args = parser.parse_args()
hab_bin = BUILD_DIR / args.build_type / f"{args.firmware}_hab.bin"
# Валидация: --firmware требует --build-type (уже есть default, но запомним)
# --bin-path: build-type игнорируется
# --erase-chip: несовместим с --ram-only
if args.erase_chip and args.ram_only:
parser.error("--erase-chip несовместим с --ram-only")
if args.fcb_path is not None and args.bin_path is None:
parser.error("--fcb-path имеет смысл только вместе с --bin-path")
if args.firmware is None and args.bin_path is None and not args.erase_chip:
parser.error("Укажи --firmware, --bin-path или --erase-chip")
# ── Определить hab_bin ──────────────────────────────────────────────────
hab_bin: Path | None = None
if args.firmware is not None:
hab_bin = BUILD_DIR / args.build_type / f"{args.firmware}_hab.bin"
elif args.bin_path is not None:
hab_bin = args.bin_path.resolve()
# ── Шапка ──────────────────────────────────────────────────────────────
print(f"\n{'' * 60}")
print(f" MIMXRT1052 Flash Tool")
print(f" Прошивка: {args.firmware} [{args.build_type}]")
print(" MIMXRT1052 Flash Tool (USB SDP)")
if args.erase_chip:
print(" Операция: chip erase")
elif hab_bin is not None:
label = (
f"{args.firmware} [{args.build_type}]" if args.firmware else str(hab_bin)
)
print(f" Прошивка: {label}")
print(f" Образ: {hab_bin}")
if args.ram_only:
print(" Режим: RAM only (без записи во Flash)")
print(f" SDP USB: {SDP_USB}")
print(f" BL USB: {BLHOST_USB}")
print(f"{'' * 60}")
print(f"{'' * 60}\n")
flash(hab_bin, ram_only=args.ram_only)
# ── Выполнить операцию ──────────────────────────────────────────────────
if args.erase_chip:
erase_chip()
elif hab_bin is not None:
flash(hab_bin, ram_only=args.ram_only, fcb_path=args.fcb_path)
if __name__ == "__main__":

276
tools/production/README.md Normal file
View file

@ -0,0 +1,276 @@
# service-tui — TUI сервисного инженера
TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе.
Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows.
> Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков —
> в [DEV_ARCH.md](DEV_ARCH.md). Этот документ — только про то, как приложением
> пользоваться.
---
## Экраны приложения
### Ожидание подключения (WaitingScreen)
Стартовый экран. Автодетект USB — как только плата подключена, TUI сам
определяет режим (прошивка или диагностика) и переключается.
```bash
┌────────────────────────────────────────────────────┐
│ service_tool v0.3.0 │
│ │
│ [LOGO_ART] │
│ │
│ Подключите плату индикатора к USB... ⠋ │
└────────────────────────────────────────────────────┘
```
При потере соединения на любом другом экране сессия разрывается полностью —
TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над
подсказкой на 4 секунды появляется строка `⚠ <причина>` (например,
«⚠ Соединение с платой потеряно»), затем скрывается сама — обычный
автодетект продолжается без вмешательства оператора.
### Прошивка платы (FlashScreen)
Триггер: обнаружена плата в режиме BootROM SDP.
```bash
┌────────────────────────────────────────────────────┐
│ ⚡ Загрузка прошивки на плату индикатора │
│ (режим BootROM) │
│ │
│ Выбор загружаемой прошивки │
│ ◉ Диагностическая прошивка (firmware_test) │
│ ○ Серийная прошивка (bootloader + tft_app) │
│ ○ Другое │
│ Файл (custom_binaries/) │
│ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое»
│ Память платы │
│ [ W25Q128 / W25Q64 ▾ ] │
│ ○ Использует SDRAM (DCD) │
│ │
│ [ ▶ Загрузить ] [ ⚠ Очистить память ] [ ✕ Выйти из приложения ] │
│ │
│ ████████████░░░░░░ ← без числового % │
│ ┌────────────────────────────────────────────┐ │
│ │ ▶ Сборка HAB-образа (nxpimage)... │ │
│ │ ▶ Прошивка: TFT_BOOTLOADER_NEW │ │
│ │ ... │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
```
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
во время активной операции (скрыт в простое), без числового `%` — только
полоса и построчный лог в реальном времени. Панель выбора прошивки
ограничена по высоте и скроллится сама, если разрастается (варианты
"Другое") — лог снизу гарантированно не сжимается меньше 6 строк.
**"Другое" — для бинарников, собранных не в этом репозитории.** В
`custom_binaries/` кладётся **сырой** образ (код + таблица векторов, без
FCB/IVT/DCD — то же самое, что `build/Debug/bootloader.bin` до `nxpimage`).
TUI сама собирает из него загружаемый образ на лету:
1. `nxpimage hab export` — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM")
2. в Flash пишется явный FCB под выбранную память платы (не тот же
auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`)
3. образ прошивается с `0x60001000`, как обычно
**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не
нужно выставлять заново на каждой следующей плате: прошили одну, вынули
USB, вставили следующую такую же — TUI уже подставила прошлый выбор,
остаётся нажать "Загрузить". Сбрасывается только при перезапуске TUI.
### Переход в рабочий режим (PostFlashScreen)
Показывается **только** после успешной прошивки `firmware_test` (для
Production/Custom этот шаг не нужен).
```bash
┌────────────────────────────────────────────────────┐
│ ✅ firmware_test успешно записан │
│ │
│ Переведите плату в нормальный режим: │
│ BOOT_MOD_1 → GND → Reset │
│ │
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
│ Автопереход через: 40с
└────────────────────────────────────────────────────┘
```
### Диагностика (DiagScreen)
Триггер: плата видна как CDC-устройство и отвечает на связь.
```bash
┌────────────────────────────────────────────────────────────────┐
│ fw: 0.2.0 MCU ID: A1B2C3D4E5F60011 M5 Bench: ✓ подключён │
├───────────────────────────┬──────────────────────────────────────┤
│ Доступные тесты │ Тест M5 Bench Статус Время │
│ [Выбрать все][Снять все] │ microSD [-] ✗ FAIL 0.1с
│ ☐ SDRAM 32 MB │ no card detected (без обрезки) │
│ ☐ QSPI Flash │ SDRAM 32 MB [-] ✓ PASS 1.8с
│ ☐ microSD │ QSPI Flash [-] ✓ PASS 0.6с
│ ☐ TFT Display │ TFT Display [-] … │
│ ☐ CAN loopback [HIL] │ Кнопки [-] │
│ ☐ Оптовходы [HIL] │ │
├──────────────────────────────────────────────────────────────────┤
│ ████████░░░░ Тест: usd — mount: ok ← только во время прогона │
├──────────────────────────────────────────────────────────────────┤
│ [▶ Запустить выбранные тесты] [▶▶ Запустить все тесты] [✕ Выйти из приложения] │
├──────────────────────────────────────────────────────────────────┤
│ ⚠ Экран залит красным цветом? 28с
│ [ ✓ Да ] [ ✗ Нет ] ← только во время confirm │
└────────────────────────────────────────────────────────────────┘
```
Что важно знать:
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
все"/"Снять все".
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
целиком, длинные сообщения переносятся на несколько строк без обрезания.
Колонка `M5 Bench` отмечает `[*]`/`[-]` — требует ли тест HIL-стенд.
- **HIL-тесты** (CAN, оптовходы) без подключённого M5StampPLC — серые,
недоступны для выбора.
- **Интерактивные проверки во время прогона теста** бывают трёх видов:
- HIL-тесты (CAN, оптовходы) при наличии M5 — отвечает стенд автоматически,
без участия оператора;
- тесты кнопок — экран показывает подсказку `⌨ <инструкция>`, ответ не
требуется, нужно физически нажать кнопку на плате;
- остальное (дисплей, аудио) — форма "Да/Нет" с таймером `⚠ <вопрос>` на
экране.
- До завершения загрузки списка тестов правая колонка показывает подсказку
«Выберите тесты и нажмите «Запустить выбранные»» вместо таблицы.
---
## Рабочие процессы сервисника
### Диагностика (firmware_test уже прошит)
```bash
1. BOOT_MOD_1 → GND, сбросить плату
2. Подключить USB → DiagScreen
3. Выбрать тесты (по умолчанию ничего не выбрано) или "Выбрать все"
4. Запустить → ответить на интерактивные запросы
5. Получить итог; FAIL-тесты — наверху таблицы, detail виден полностью
```
### Перепрошивка firmware_test
```bash
1. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
2. Выбрать «Диагностическая прошивка (firmware_test)» → Загрузить
3. PostFlashScreen: BOOT_MOD_1 → GND, сбросить плату
4. Нажать "Готово" (или дождаться авто-перехода через 40с)
5. TUI автоматически попадает в DiagScreen при следующем подключении
```
### Прошивка стороннего бинарника (custom_binaries/)
Для плат старых ревизий и любых образов, собранных не в этом репозитории.
```bash
1. Положить сырой бинарник (без FCB/IVT/DCD) в custom_binaries/
(или в директорию из SERVICE_CUSTOM_BINARIES_DIR)
2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD при необходимости
4. Загрузить — TUI сама соберёт HAB-образ и запишет правильный FCB
5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже
подставлен, останется нажать «Загрузить»
```
### Chip Erase
```bash
1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3)
2. FlashScreen → Очистить память (~30 с для W25Q128, дольше для W25Q512)
3. После erase BootROM не загрузит прошивку — требуется перепрошить
```
---
## Конфигурация (`.env`)
```ini
# USB VID:PID — BootROM SDP (константы NXP, не менять)
BOOTROM_VID=1fc9
BOOTROM_PID=0130
# USB VID:PID — Flashloader (константы NXP, не менять)
FLASHLOADER_VID=15a2
FLASHLOADER_PID=0073
# USB VID:PID — firmware_test CDC (наше устройство)
SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad
# USB VID:PID — M5StampPLC (детект HIL-стенда)
SERVICE_M5_VID=303a
SERVICE_M5_PID=4001
# Директория с сырыми кастомными бинарниками для FlashScreen → "Другое".
# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом
# с main.py в dev-режиме); создаётся автоматически при старте.
# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries
# Тип сборки firmware_test для прошивки (Debug | Release).
# Release временно нестабилен — по умолчанию Debug.
FIRMWARE_BUILD_TYPE=Debug
# Опционально: путь к директории лога TUI
# SERVICE_LOG_DIR=/tmp
```
---
## Запуск
### Из монорепозитория (разработчик)
```bash
just host::service-setup # установить зависимости tools/production/
just host::service-tui # запустить TUI
```
### Standalone-бинарь (сервисник)
```bash
just host::service-build
# → tools/production/dist/service_tui
```
> Standalone-бинарь не включает `tools/host/` — для прошивки рядом нужен
> инициализированный `tools/host/` (`just host::setup-tools`), либо
> абсолютный путь в `_FLASH_USB_SCRIPT` (`flasher.py`).
---
## Зависимости
| Пакет | Версия | Назначение |
| --------------- | ------ | ----------------------------------------------------- |
| `textual` | ≥ 0.80 | TUI фреймворк |
| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial |
| `pyusb` | ≥ 1.0 | детект BootROM SDP (не виден через pyserial на macOS) |
| `python-dotenv` | ≥ 1.0 | загрузка `.env` |
| `pyinstaller` | ≥ 6.0 | сборка standalone-бинаря |
**Runtime-зависимость (не в `pyproject.toml`):** `flasher.py` вызывает
`tools/host/flash_usb.py` через `uv run``tools/host/` должен быть
инициализирован (`just host::setup-tools`).
---
## Логирование
```bash
tools/production/service_tui.log ← по умолчанию
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env
```
Уровень: `DEBUG` для модулей приложения, `WARNING` для самого textual. TUI не
пишет в stdout — Textual захватывает терминал.

125
tools/production/app/app.py Normal file
View file

@ -0,0 +1,125 @@
"""
app.py корневое Textual приложение.
Управляет сменой экранов и жизненным циклом клиентов
(FirmwareClient, M5Client).
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from textual import on, work
from textual.app import App
from textual.binding import Binding
from .firmware_client import FirmwareClient
from .m5_client import M5Client
from .models import AppMode, FlashPreset, FlashTarget
from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen
logger = logging.getLogger(__name__)
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16)
_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16)
class ServiceApp(App):
"""Корневое приложение service-tui."""
TITLE = "TFT Board Service Tool"
CSS_PATH = "app.tcss" # относительно app/app.py → app/app.tcss
BINDINGS = [
Binding("ctrl+c", "quit", "Выход", show=True),
Binding("ctrl+q", "quit", "Выход"),
]
def __init__(self) -> None:
super().__init__()
self._fw: Optional[FirmwareClient] = None
self._m5: Optional[M5Client] = None
# «Липкий» выбор оператора на FlashScreen — переносится на следующую
# плату в рамках одного запуска TUI (см. FlashPreset docstring).
# Сбрасывается при перезапуске TUI, не персистится на диск.
self._last_flash_preset = FlashPreset()
def on_mount(self) -> None:
self.push_screen(WaitingScreen())
# ── Переходы между экранами ───────────────────────────────────────────────
@on(WaitingScreen.DeviceDetected)
@on(WaitingScreen.DeviceDetected)
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
if event.mode == AppMode.FLASHING:
self.switch_screen(FlashScreen(preset=self._last_flash_preset))
elif event.mode == AppMode.DIAGNOSING:
self._connect_and_diagnose()
@on(FlashScreen.FlashDone)
def _on_flash_done(self, event: FlashScreen.FlashDone) -> None:
"""
После прошивки:
- firmware_test + успех PostFlashScreen (промпт смены BootMode)
- всё остальное (production/custom, либо ошибка) WaitingScreen
- target=None маркер потери соединения (см. ConnectionWatcherMixin)
WaitingScreen с явной причиной возврата
"""
if event.preset is not None:
self._last_flash_preset = event.preset
if event.target is None and not event.success:
self.switch_screen(
WaitingScreen(disconnect_reason="Соединение с платой потеряно")
)
return
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())
@on(DiagScreen.DiagDone)
def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
"""После диагностики — отключиться, вернуться в Waiting."""
self._disconnect()
self.switch_screen(WaitingScreen(disconnect_reason=event.reason))
# ── Подключение к firmware_test ───────────────────────────────────────────
@work(thread=False)
async def _connect_and_diagnose(self) -> None:
try:
self._fw = await FirmwareClient.auto_connect(vid=_CDC_VID, pid=_CDC_PID)
except Exception as exc:
logger.error("CDC connect failed: %s", exc)
self.switch_screen(WaitingScreen())
return
self._m5 = await M5Client.auto_connect()
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:
if self._fw is not None:
self.call_later(self._fw.disconnect)
self._fw = None
if self._m5 is not None:
self.call_later(self._m5.disconnect)
self._m5 = None

View file

@ -0,0 +1,391 @@
/* ═══════════════════════════════════════════════════════════
service-tui — единый файл стилей
Путь резолвится относительно app/app.py → app/app.tcss
═══════════════════════════════════════════════════════════ */
/* ── Общая рамка приложения (AppFrame) ──────────────────── */
/* Все три экрана центрируют один AppFrame фиксированного */
/* размера — единообразный визуальный каркас независимо от */
/* размера окна терминала (как в ratatui-приложениях). */
Screen {
align: center middle;
}
AppFrame {
width: 100%;
height: 100%;
max-width: 112;
max-height: 40;
border: heavy $primary;
background: $surface;
padding: 1 2;
}
/* ── WaitingScreen ──────────────────────────────────────── */
#waiting-frame {
align: center top;
}
#waiting-logo-row {
width: 100%;
height: auto;
margin-top: 1;
}
#waiting-logo-art {
width: auto;
}
#waiting-version-row {
width: 100%;
height: auto;
margin-top: 1;
}
#waiting-version {
color: $warning;
text-style: bold;
text-align: center;
margin-bottom: 1;
}
#waiting-reason {
margin-top: 1;
padding: 1 2;
border: round $warning;
color: $warning;
text-style: bold;
content-align: center middle;
}
#waiting-reason.hidden {
display: none;
}
#waiting-hint {
margin-top: 2;
color: $text-muted;
content-align: center middle;
}
#waiting-spinner {
margin-top: 1;
color: $accent;
content-align: center middle;
}
/* ── FlashScreen ────────────────────────────────────────── */
#flash-title {
text-style: bold;
color: $warning;
margin-bottom: 1;
}
#flash-target-group {
border: round $panel;
padding: 1 2;
margin-bottom: 1;
height: auto;
max-height: 18;
overflow-y: auto;
}
#flash-target-group .section-title {
text-style: bold;
color: $text-muted;
padding: 0 0 1 0;
}
#flash-custom-group {
margin-top: 1;
height: auto;
}
#flash-custom-group.hidden {
display: none;
}
#flash-custom-select,
#flash-fcb-select {
width: 1fr;
margin-bottom: 1;
}
#flash-dcd-row {
height: auto;
align: left middle;
margin-top: 1;
}
#flash-dcd-switch {
margin-right: 1;
}
#flash-btn-row {
height: auto;
margin-top: 1;
align: left middle;
}
#flash-btn-flash,
#flash-btn-erase {
margin-right: 2;
}
#flash-btn-flash:disabled,
#flash-btn-erase:disabled,
#flash-btn-quit:disabled {
background: $panel;
color: $text-muted;
}
#flash-progress-bar {
margin-top: 1;
}
#flash-progress-bar.hidden {
display: none;
}
#flash-log {
height: 1fr;
min-height: 6;
margin-top: 1;
border: round $panel;
background: $surface-darken-1;
overflow-y: auto;
}
/* ── PostFlashScreen ────────────────────────────────────── */
#post-flash-frame {
align: center middle;
}
#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;
}
#post-flash-title-row,
#post-flash-instruction-row {
width: 100%;
height: auto;
}
/* ── DiagScreen ─────────────────────────────────────────── */
#diag-frame {
layout: vertical;
padding: 0;
}
#diag-header {
height: 3;
background: $panel;
padding: 0 2;
align: left middle;
}
#diag-header-fw {
width: auto;
min-width: 14;
color: $text-muted;
margin-right: 3;
}
#diag-header-uid {
width: auto;
min-width: 28;
color: $text-muted;
margin-right: 3;
}
#diag-header-m5 {
width: auto;
color: $success;
}
#diag-header-m5.m5-absent {
color: $text-muted;
}
#diag-main {
height: 1fr;
min-height: 10;
}
#diag-progress-row {
height: auto;
padding: 0 2;
}
#diag-progress-row.hidden {
display: none;
}
#diag-progress-bar {
height: 1;
margin-top: 1;
}
#diag-progress-label {
height: 1;
color: $text-muted;
}
#diag-btn-row {
height: 3;
align: left middle;
padding: 0 2;
background: $panel;
}
#diag-btn-run-selected,
#diag-btn-run-all {
margin-right: 2;
}
#diag-btn-run-selected:disabled,
#diag-btn-run-all:disabled {
background: $panel;
color: $text-muted;
}
/* ── TestListPanel ──────────────────────────────────────── */
TestListPanel {
width: 38;
border-right: solid $panel;
padding: 1 1;
overflow-y: auto;
}
TestListPanel .section-title {
text-style: bold;
color: $text-muted;
padding: 0 0 1 0;
}
#test-list-select-row {
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 {
width: 6;
color: $accent;
}
.test-row-hil-badge.hil-disabled {
color: $text-muted;
}
/* ── ResultsPanel ───────────────────────────────────────── */
ResultsPanel {
width: 70;
padding: 1 2;
overflow-y: auto;
}
ResultsPanel .section-title {
text-style: bold;
color: $text-muted;
padding: 0 0 1 0;
}
#results-empty {
height: 1fr;
width: 100%;
}
#results-empty.hidden {
display: none;
}
#results-empty-text {
color: $text-muted;
text-align: center;
}
#results-table {
height: auto;
max-height: 100%;
}
#results-table.hidden {
display: none;
}
/* ── ConfirmPanel ───────────────────────────────────────── */
ConfirmPanel {
height: auto;
background: $warning 15%;
border-top: solid $warning;
padding: 1 2;
align: left middle;
}
ConfirmPanel.hidden {
display: none;
}
#confirm-prompt {
width: 1fr;
color: $warning;
text-style: bold;
}
#confirm-countdown {
color: $text-muted;
margin-right: 2;
}
#confirm-btn-ok {
margin-right: 1;
}
#confirm-btn-ok.hidden,
#confirm-btn-fail.hidden {
display: none;
}

View file

@ -0,0 +1,33 @@
"""
boot_art.py ASCII-логотип компании для WaitingScreen.
Сгенерирован программно (coverage-based растеризация logo.png в
символы плотности + разделение по цвету blue/dark), затем зафиксирован
как статичная строка рантайм-зависимости на Pillow/etc. не требуется.
"""
from __future__ import annotations
LOGO_ART = """
[grey37]........[/grey37][#3ca0dc].**:+*::*+.[/#3ca0dc][grey37]..........[/grey37]
[grey37]........[/grey37][#3ca0dc].##:*#++#*.[/#3ca0dc][grey37]..........[/grey37]
[grey37].........[/grey37][#3ca0dc]*#[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#..#+[/#3ca0dc][grey37]...........[/grey37]
[grey37].......[/grey37][#3ca0dc].+#:[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#..#+[/#3ca0dc][grey37]...........[/grey37]
[grey37].....[/grey37][#3ca0dc].+#+..:#*..#+[/#3ca0dc][grey37]...........[/grey37]
[grey37]...[/grey37][#3ca0dc].+#+..:**:..*#.[/#3ca0dc][white].:.[/white][grey37]........[/grey37]
[grey37].[/grey37][#3ca0dc].+#+..:#*:..*#+.[/#3ca0dc][white]+@@+[/white][grey37]........[/grey37]
[grey37].[/grey37][#3ca0dc]##..:#*.[/#3ca0dc][grey37].[/grey37][#3ca0dc].*#+.[/#3ca0dc][white]%@@@@%[/white][grey37].[/grey37][white]::.[/white][grey37]....[/grey37]
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#:[/#3ca0dc][grey37].[/grey37][#3ca0dc].*#+.[/#3ca0dc][white]+@@@@@@%[/white][grey37].[/grey37][white]+@...[/white][grey37]..[/grey37]
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#..#+.[/#3ca0dc][grey37]........[/grey37][white]%%[/white][grey37].[/grey37][white]+@..%.[/white][grey37].[/grey37]
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#:[/#3ca0dc][grey37].........[/grey37][white]%%[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
[grey37].[/grey37][#3ca0dc]**[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#:[/#3ca0dc][grey37]........[/grey37][white].%%[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
[grey37].[/grey37][#3ca0dc]..[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#*+++++.[/#3ca0dc][white].:@%.[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
[grey37]....[/grey37][#3ca0dc].*.:#####+.[/#3ca0dc][white]+@%.[/white][grey37].[/grey37][white]:%%:[/white][grey37].[/grey37][white]:@:[/white][grey37].[/grey37]
[grey37]......[/grey37][#3ca0dc].:###+.[/#3ca0dc][white]+@%.[/white][grey37].[/grey37][white].%%:..+@+.[/white][grey37].[/grey37]
[grey37].......[/grey37][#3ca0dc].:+.[/#3ca0dc][white]:@%.[/white][grey37].[/grey37][white].%@:..+@+.[/white][grey37]...[/grey37]
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white].%@:..+@+.[/white][grey37].....[/grey37]
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white]%@..+@+.[/white][grey37].......[/grey37]
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white]%@[/white][grey37].[/grey37][white]:@:[/white][grey37].........[/grey37]
[grey37]..........[/grey37][white].@%.%@.+@+[/white][grey37].........[/grey37]
[grey37]..........[/grey37][white]:@@:@@+%@%.[/white][grey37]........[/grey37]
""".strip("\n")

View file

@ -0,0 +1,253 @@
"""
firmware_client.py async CDC-клиент firmware_test.
Транспорт: USB CDC ACM (pyserial в asyncio thread executor).
Протокол: JSON-lines v2 (один JSON-объект на строку, завершается '\\n').
Публичный API:
FirmwareClient.connect() открыть порт, проверить pingpong
FirmwareClient.disconnect() закрыть порт
FirmwareClient.ping() ping pong, вернуть True/False
FirmwareClient.list_tests() list_tests list[TestInfo]
FirmwareClient.run_selected() запустить тесты, вернуть AsyncGenerator событий
FirmwareClient.send_confirm() отправить confirm
FirmwareClient.get_uid() get_uid str (hex UID)
FirmwareClient.get_version() get_version str (X.Y.Z)
FirmwareClient.send_cmd_raw() отправить произвольную JSON-команду
Все blocking-операции с pyserial выполняются в run_in_executor()
чтобы не блокировать event loop Textual.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncGenerator, Optional
import serial
import serial.tools.list_ports
from .models import TestInfo
logger = logging.getLogger(__name__)
# Таймаут чтения одной строки (сек)
_READLINE_TIMEOUT_S = 0.1
# Таймаут ping→pong при подключении (сек)
_PING_TIMEOUT_S = 5.0
# Таймаут ожидания событий теста (сек) — длиннее самого долгого теста (SDRAM ~15 с)
_TEST_EVENT_TIMEOUT_S = 120.0
def _find_cdc_port(vid: int, pid: int) -> Optional[str]:
"""Найти первый CDC-порт с заданным VID/PID."""
for info in serial.tools.list_ports.comports():
if info.vid == vid and info.pid == pid:
return info.device
return None
def _parse_event(line: str) -> Optional[dict]:
"""Распарсить JSON-строку. Вернуть None при ошибке."""
line = line.strip()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
logger.warning("Bad JSON from firmware: %r", line)
return None
class FirmwareClient:
"""
Async-клиент для общения с firmware_test по USB CDC ACM.
Пример использования::
client = FirmwareClient(port="/dev/ttyACM0", baudrate=115200)
await client.connect()
tests = await client.list_tests()
async for event in client.run_selected([t.id for t in tests]):
...
await client.disconnect()
"""
def __init__(self, port: str, baudrate: int = 115200) -> None:
self._port = port
self._baudrate = baudrate
self._ser: Optional[serial.Serial] = None
self._lock = asyncio.Lock()
# ── Connection ──────────────────────────────────────────────────────────
async def connect(self) -> None:
"""Открыть порт и проверить связь через ping→pong."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._open)
ok = await self.ping()
if not ok:
await self.disconnect()
raise ConnectionError(f"firmware_test не отвечает на ping: {self._port}")
def _open(self) -> None:
self._ser = serial.Serial(
port=self._port,
baudrate=self._baudrate,
timeout=_READLINE_TIMEOUT_S,
)
# сбросить входной буфер — session_start уже ушёл при старте прошивки
self._ser.reset_input_buffer()
async def disconnect(self) -> None:
"""Закрыть порт."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._close)
def _close(self) -> None:
if self._ser and self._ser.is_open:
self._ser.close()
self._ser = None
@classmethod
async def auto_connect(
cls, vid: int, pid: int, baudrate: int = 115200
) -> "FirmwareClient":
"""
Найти CDC-порт по VID/PID и подключиться.
:raises RuntimeError: если порт не найден.
:raises ConnectionError: если ping не прошёл.
"""
port = _find_cdc_port(vid, pid)
if port is None:
raise RuntimeError(f"CDC-порт с VID={vid:04X}:PID={pid:04X} не найден")
client = cls(port=port, baudrate=baudrate)
await client.connect()
return client
# ── Low-level I/O ───────────────────────────────────────────────────────
def _write_line(self, obj: dict) -> None:
"""Сериализовать dict в JSON и отправить строку (blocking)."""
assert self._ser is not None
line = json.dumps(obj, separators=(",", ":")) + "\n"
self._ser.write(line.encode("utf-8"))
self._ser.flush()
def _read_line(self) -> Optional[dict]:
"""Прочитать одну строку и распарсить (blocking, таймаут _READLINE_TIMEOUT_S)."""
assert self._ser is not None
raw = self._ser.readline()
if not raw:
return None
return _parse_event(raw.decode("utf-8", errors="replace"))
async def _send(self, obj: dict) -> None:
"""Отправить JSON-команду (async wrapper)."""
loop = asyncio.get_running_loop()
async with self._lock:
await loop.run_in_executor(None, self._write_line, obj)
async def _recv_until(
self,
stop_types: set[str],
timeout_s: float = _TEST_EVENT_TIMEOUT_S,
) -> AsyncGenerator[dict, None]:
"""
Читать события до получения одного из stop_types или таймаута.
Генератор yield каждого полученного события.
При таймауте yield-ит синтетическое событие
{"type": "_timeout", "timeout_s": ...} перед завершением вызывающий
код (Orchestrator) должен явно обработать этот тип и не путать его
с обрывом связи без объяснения причины. Префикс "_" отличает это
от реальных событий протокола firmware_test.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
event = await loop.run_in_executor(None, self._read_line)
if event is None:
await asyncio.sleep(0)
continue
yield event
if event.get("type") in stop_types:
return
logger.warning("_recv_until timeout after %.1f s", timeout_s)
yield {"type": "_timeout", "timeout_s": timeout_s}
# ── Public API ──────────────────────────────────────────────────────────
async def ping(self) -> bool:
"""Отправить ping, ждать pong. Вернуть True при успехе."""
await self._send({"type": "cmd", "cmd": "ping"})
loop = asyncio.get_running_loop()
deadline = loop.time() + _PING_TIMEOUT_S
while loop.time() < deadline:
event = await loop.run_in_executor(None, self._read_line)
if event and event.get("type") == "pong":
return True
await asyncio.sleep(0)
return False
async def get_version(self) -> str:
"""Запросить версию firmware_test. Вернуть строку 'X.Y.Z' или ''."""
await self._send({"type": "cmd", "cmd": "get_version"})
async for event in self._recv_until({"version_response"}, timeout_s=3.0):
if event.get("type") == "version_response":
return event.get("fw", "")
return ""
async def list_tests(self) -> list[TestInfo]:
"""Запросить список тестов. Вернуть list[TestInfo]."""
await self._send({"type": "cmd", "cmd": "list_tests"})
async for event in self._recv_until({"test_list"}, timeout_s=5.0):
if event.get("type") == "test_list":
return [
TestInfo(
id=t["id"],
name=t["name"],
critical=t.get("critical", False),
requires_hil=t.get("requires_hil", False),
)
for t in event.get("tests", [])
]
return []
async def run_selected(self, test_ids: list[str]) -> AsyncGenerator[dict, None]:
"""
Запустить выбранные тесты. Возвращает async generator событий:
test_begin, test_result, confirm_request, summary, error.
Вызывающий код должен обрабатывать confirm_request и вызывать
send_confirm() не дожидаясь следующего события.
"""
await self._send({"type": "cmd", "cmd": "run_selected", "tests": test_ids})
async for event in self._recv_until(
{"summary"}, timeout_s=_TEST_EVENT_TIMEOUT_S
):
yield event
async def send_confirm(self, confirm_id: str, confirmed: bool) -> None:
"""Отправить confirm в ответ на confirm_request."""
await self._send({"type": "confirm", "id": confirm_id, "confirmed": confirmed})
async def get_uid(self) -> str:
"""Запросить OCOTP UID. Вернуть hex-строку (16 символов) или ""."""
await self._send({"type": "cmd", "cmd": "get_uid"})
async for event in self._recv_until({"uid_response"}, timeout_s=3.0):
if event.get("type") == "uid_response":
return event.get("uid", "")
return ""
async def send_cmd_raw(self, cmd: dict) -> None:
"""Отправить произвольную команду (для отладки/расширения)."""
await self._send(cmd)
@staticmethod
def find_port(vid: int, pid: int) -> Optional[str]:
"""Найти CDC-порт по VID/PID. None если не найден."""
return _find_cdc_port(vid, pid)

View file

@ -0,0 +1,430 @@
"""
flasher.py обёртка над tools/host/flash_usb.py для TUI.
Запускает flash_usb.py как subprocess, парсит stdout для прогресса,
пробрасывает события через asyncio.Queue в TUI.
Не дублирует spsdk-окружение переиспользует tools/host/ uv-проект.
Публичный API:
Flasher.flash(target, bin_path, progress_cb) async, прогресс через callback
Flasher.erase_chip(progress_cb) async chip erase
Flasher.detect_sdp() проверить наличие BootROM SDP
Flasher.detect_cdc() проверить наличие CDC firmware_test
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Awaitable, Callable, Optional
from .models import FcbVariant, FlashProgress, FlashTarget
logger = logging.getLogger(__name__)
# VID/PID констант — читаются из env, fallback на известные значения
_BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16)
_BOOTROM_PID = int(os.environ.get("BOOTROM_PID", "0x0130"), 16)
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 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_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py"
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent
_HAB_DIR = _HOST_TOOLS_DIR / "hab"
_DCD_DIR = _HOST_TOOLS_DIR / "dcd"
def _resolve_custom_binaries_dir() -> Path:
"""
Директория с «сырыми» кастомными бинарниками для FlashScreen.
Не пакуется в PyInstaller-бандл внешняя директория, путь к которой
можно переопределить через SERVICE_CUSTOM_BINARIES_DIR. sys.executable
указывает на реальный exe и для --onefile, и для --onedir (в отличие
от sys._MEIPASS временной распаковки onefile).
"""
override = os.environ.get("SERVICE_CUSTOM_BINARIES_DIR")
if override:
base = Path(override)
elif getattr(sys, "frozen", False):
base = Path(sys.executable).resolve().parent / "custom_binaries"
else:
base = Path(__file__).parents[1] / "custom_binaries"
base.mkdir(parents=True, exist_ok=True)
return base
CUSTOM_BINARIES_DIR = _resolve_custom_binaries_dir()
# Паттерны stdout flash_usb.py для извлечения прогресса
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
_RE_PHASE = re.compile(r"(sdphost|blhost|Writing|Erasing|Verifying)", re.IGNORECASE)
ProgressCallback = Callable[[FlashProgress], Awaitable[None]]
def _detect_usb(vid: int, pid: int) -> bool:
"""
Проверить наличие USB-устройства по VID/PID (синхронно).
Два метода детекта:
1. pyusb (usb.core) видит все USB-устройства включая SDP bulk/HID.
На macOS SDP-устройство (1FC9:0130) не создаёт serial-порт
и невидимо через serial.tools.list_ports.
2. serial.tools.list_ports fallback для CDC ACM устройств.
"""
# Метод 1: pyusb — работает для SDP и CDC
try:
import usb.core
dev = usb.core.find(idVendor=vid, idProduct=pid)
if dev is not None:
return True
except Exception:
pass
# Метод 2: serial list_ports — fallback для CDC ACM
try:
import serial.tools.list_ports
for info in serial.tools.list_ports.comports():
if info.vid == vid and info.pid == pid:
return True
except Exception:
pass
return False
class Flasher:
"""
Async-обёртка над flash_usb.py.
Пример::
flasher = Flasher()
await flasher.flash(
target=FlashTarget.FIRMWARE_TEST,
progress_cb=lambda p: print(p.message),
)
"""
def __init__(self) -> None:
self._proc: Optional[asyncio.subprocess.Process] = None
# ── Detection ───────────────────────────────────────────────────────────
@staticmethod
def detect_sdp() -> bool:
"""True если виден BootROM SDP (1FC9:0130)."""
return _detect_usb(_BOOTROM_VID, _BOOTROM_PID)
@staticmethod
def detect_cdc() -> bool:
"""True если виден CDC firmware_test (1996:00AD)."""
return _detect_usb(_CDC_VID, _CDC_PID)
@staticmethod
def list_custom_binaries() -> list[Path]:
"""Отсканировать custom_binaries/ на *.bin, отсортировано по имени."""
return sorted(CUSTOM_BINARIES_DIR.glob("*.bin"))
# ── Erase ────────────────────────────────────────────────────────────────
async def erase_chip(
self,
progress_cb: Optional[ProgressCallback] = None,
) -> bool:
"""
Chip erase Flash через USB SDP (flash_usb.py --erase-chip).
Занимает ~30 с для W25Q128. FCB будет стёрт.
:return: True при успехе.
"""
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--erase-chip",
]
return await self._run_cmd(cmd, "erase", progress_cb)
# ── Flash ────────────────────────────────────────────────────────────────
async def flash(
self,
target: FlashTarget,
progress_cb: Optional[ProgressCallback] = None,
bin_path: Optional[Path] = None,
use_dcd: bool = False,
fcb_variant: FcbVariant = FcbVariant.W25Q128,
) -> bool:
"""
Запустить прошивку через flash_usb.py.
:param target: Что прошиваем (firmware_test, production или custom).
:param progress_cb: Async callback с FlashProgress (может быть None).
:param bin_path: Путь к бинарю (обязателен для CUSTOM).
:param use_dcd: Только для CUSTOM включить DCDFilePath (SDRAM-init)
при сборке HAB-образа через nxpimage.
:param fcb_variant: Только для CUSTOM какой явный FCB-блоб (dcd/*_fdcb.bin)
записать в Flash[0x60000000] вместо auto-config.
:return: True при успехе.
"""
if target == FlashTarget.FIRMWARE_TEST:
if bin_path is not None:
return await self._run_flash_bin(bin_path, progress_cb)
return await self._run_flash("firmware_test", progress_cb)
elif target == FlashTarget.PRODUCTION:
ok = await self._run_flash("bootloader", progress_cb)
if ok:
ok = await self._run_flash("app", progress_cb)
return ok
elif target == FlashTarget.CUSTOM:
if bin_path is None:
raise ValueError("FlashTarget.CUSTOM требует bin_path")
return await self._run_flash_custom(
bin_path, use_dcd, fcb_variant, progress_cb
)
return False
async def _run_flash_custom(
self,
raw_bin_path: Path,
use_dcd: bool,
fcb_variant: FcbVariant,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""
Прошить «сырой» (не-HAB) кастомный бинарник из custom_binaries/.
Два шага:
1. Собрать HAB-образ (IVT + опционально DCD, БЕЗ FCB) через
nxpimage так же, как just build::hab-* собирает штатные
прошивки, только конфиг генерируется на лету под выбранный файл.
2. Прошить получившийся HAB-образ через flash_usb.py --bin-path,
подставив явный FCB-блоб (--fcb-path) под выбранный тип памяти
см. FcbVariant.
"""
if progress_cb is not None:
await progress_cb(
FlashProgress(
phase="nxpimage", percent=0, message="Сборка HAB-образа..."
)
)
hab_bin = await self._build_custom_hab(raw_bin_path, use_dcd, progress_cb)
if hab_bin is None:
if progress_cb is not None:
await progress_cb(
FlashProgress(
phase="error",
percent=0,
message="Ошибка сборки HAB-образа (nxpimage)",
)
)
return False
fcb_path = _DCD_DIR / fcb_variant.fcb_filename
try:
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--bin-path",
str(hab_bin),
"--fcb-path",
str(fcb_path),
]
return await self._run_cmd(cmd, raw_bin_path.stem, progress_cb)
finally:
hab_bin.unlink(missing_ok=True)
async def _build_custom_hab(
self,
raw_bin: Path,
use_dcd: bool,
progress_cb: Optional[ProgressCallback] = None,
) -> Optional[Path]:
"""
Собрать HAB-образ из сырого бинарника через nxpimage.
Временный YAML пишется прямо в tools/host/hab/ (как и штатные
hab_*.yaml) и nxpimage запускается с cwd=tools/host/hab/ это
обязательно: относительный DCDFilePath ("../dcd/dcd.bin") в
существующих конфигах резолвится именно так (см. build.just,
группа hab_image_gen `cd tools/host/hab && uv run nxpimage ...`).
Отходить от этой схемы рискованно nxpimage не документирует
явно, от чего резолвит относительные пути.
:return: путь к собранному *.hab.bin, либо None при ошибке nxpimage.
"""
yaml_f = tempfile.NamedTemporaryFile(
dir=_HAB_DIR, suffix=".yaml", prefix="_tui_custom_", delete=False
)
yaml_path = Path(yaml_f.name)
out_path = yaml_path.with_suffix(".hab.bin")
lines = [
"options:",
" flags: 0x00",
" startAddress: 0x60000000",
" ivtOffset: 0x1000",
" initialLoadSize: 0x2000",
" family: mimxrt1050",
]
if use_dcd:
lines.append(" DCDFilePath: ../dcd/dcd.bin")
lines.append(f'inputImageFile: "{raw_bin.resolve()}"')
lines.append("sections: []")
try:
yaml_f.write("\n".join(lines).encode("utf-8"))
yaml_f.close()
cmd = [
"uv",
"run",
"nxpimage",
"hab",
"export",
"--force",
"-c",
str(yaml_path),
"-o",
str(out_path),
]
logger.info("Building custom HAB image: %s", " ".join(cmd))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(_HAB_DIR),
)
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.decode("utf-8", errors="replace").rstrip()
logger.debug("nxpimage: %s", line)
if progress_cb is not None and line:
await progress_cb(
FlashProgress(phase="nxpimage", percent=0, message=line)
)
await proc.wait()
if proc.returncode != 0 or not out_path.exists():
logger.error("nxpimage hab export failed (rc=%s)", proc.returncode)
return None
return out_path
finally:
yaml_path.unlink(missing_ok=True)
async def _run_flash(
self,
firmware: str,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Запустить flash_usb.py для одного бинаря (стандартный firmware)."""
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--firmware",
firmware,
"--build-type",
_FIRMWARE_BUILD_TYPE,
]
return await self._run_cmd(cmd, firmware, progress_cb)
async def _run_flash_bin(
self,
bin_path: Path,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Запустить flash_usb.py для произвольного бинаря."""
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--bin-path",
str(bin_path.resolve()),
]
return await self._run_cmd(cmd, bin_path.stem, progress_cb)
async def _run_cmd(
self,
cmd: list[str],
label: str,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Общий subprocess runner для всех операций flash_usb.py."""
logger.info("Running: %s", " ".join(cmd))
try:
self._proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(_HOST_TOOLS_DIR),
)
assert self._proc.stdout is not None
async for raw_line in self._proc.stdout:
line = raw_line.decode("utf-8", errors="replace").rstrip()
logger.debug("flash_usb [%s]: %s", label, line)
if progress_cb is not None:
progress = _parse_progress(line)
if progress is not None:
await progress_cb(progress)
await self._proc.wait()
success = self._proc.returncode == 0
if progress_cb is not None:
phase = "done" if success else "error"
msg = "Завершено" if success else "Ошибка"
await progress_cb(FlashProgress(phase=phase, percent=100, message=msg))
return success
except Exception as exc:
logger.error("Subprocess error [%s]: %s", label, exc)
if progress_cb is not None:
await progress_cb(
FlashProgress(phase="error", percent=0, message=str(exc))
)
return False
finally:
self._proc = None
def _parse_progress(line: str) -> Optional[FlashProgress]:
"""
Извлечь прогресс из строки stdout flash_usb.py.
Возвращает None если строка не несёт прогресс-информации.
"""
percent_m = _RE_PERCENT.search(line)
phase_m = _RE_PHASE.search(line)
if percent_m is None and phase_m is None:
return None
percent = int(percent_m.group(1)) if percent_m else 0
phase = phase_m.group(1).lower() if phase_m else "flash"
return FlashProgress(phase=phase, percent=percent, message=line.strip())

View file

@ -0,0 +1,188 @@
"""
m5_client.py клиент M5StampPLC для TUI.
Тонкая async-обёртка над синхронным JSON-lines протоколом M5 агента.
Blocking-вызовы выполняются в executor чтобы не блокировать Textual.
Поддерживаемые команды агента (JSON-lines):
{"cmd": "ping"}
{"cmd": "relay_set", "relay": 1..4, "state": true/false}
{"cmd": "relay_get", "relay": 1..4}
{"cmd": "can_send", "id": 0x100, "data": [0xDE, 0xAD, 0xBE, 0xEF]}
{"cmd": "can_recv", "timeout_ms": 500}
Карта реле (из HIL_BENCH.md):
RLY1 питание таргета (VIN)
RLY2 RS_RX (оптовход RS)
RLY3 EXT_IN1 (оптовход IN1)
RLY4 EXT_IN2 (оптовход IN2)
"""
from __future__ import annotations
import os
import asyncio
import json
import logging
from typing import Optional
import serial
import serial.tools.list_ports
logger = logging.getLogger(__name__)
# VID/PID M5StampPLC
_M5_VID = int(os.environ.get("SERVICE_M5_VID", "0x303A"), 16)
_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)
_READLINE_TIMEOUT_S = 0.5
_CMD_TIMEOUT_S = 3.0
def _find_m5_port() -> Optional[str]:
"""Автодетект M5StampPLC по VID/PID."""
for info in serial.tools.list_ports.comports():
if info.vid == _M5_VID and info.pid == _M5_PID:
return info.device
return None
class M5Client:
"""
Async-клиент для M5StampPLC.
Пример::
m5 = await M5Client.auto_connect()
if m5:
await m5.relay_set(3, True) # RLY3 ON → EXT_IN1 ACTIVE
await m5.relay_set(3, False) # RLY3 OFF
await m5.disconnect()
"""
def __init__(self, port: str, baudrate: int = 115200) -> None:
self._port = port
self._baudrate = baudrate
self._ser: Optional[serial.Serial] = None
self._lock = asyncio.Lock()
# ── Connection ──────────────────────────────────────────────────────────
@classmethod
async def auto_connect(cls, baudrate: int = 115200) -> Optional["M5Client"]:
"""
Попытаться найти и подключиться к M5StampPLC.
Вернуть None если не найден M5 опционален.
"""
port = _find_m5_port()
if port is None:
logger.info("M5StampPLC не найден")
return None
client = cls(port=port, baudrate=baudrate)
try:
await client.connect()
return client
except Exception as exc:
logger.warning("M5StampPLC connect failed: %s", exc)
return None
async def connect(self) -> None:
"""Открыть порт и проверить связь через ping."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._open)
ok = await self.ping()
if not ok:
await self.disconnect()
raise ConnectionError(f"M5StampPLC не отвечает: {self._port}")
def _open(self) -> None:
self._ser = serial.Serial(
port=self._port,
baudrate=self._baudrate,
timeout=_READLINE_TIMEOUT_S,
)
self._ser.reset_input_buffer()
async def disconnect(self) -> None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._close)
def _close(self) -> None:
if self._ser and self._ser.is_open:
self._ser.close()
self._ser = None
@property
def connected(self) -> bool:
return self._ser is not None and self._ser.is_open
# ── Low-level I/O ───────────────────────────────────────────────────────
def _send_recv(self, cmd: dict) -> Optional[dict]:
"""Отправить команду, прочитать ответ (blocking)."""
assert self._ser is not None
line = json.dumps(cmd, separators=(",", ":")) + "\n"
self._ser.write(line.encode("utf-8"))
self._ser.flush()
raw = self._ser.readline()
if not raw:
return None
try:
return json.loads(raw.decode("utf-8", errors="replace").strip())
except json.JSONDecodeError:
return None
async def _cmd(self, cmd: dict) -> Optional[dict]:
"""Async wrapper над _send_recv."""
loop = asyncio.get_running_loop()
async with self._lock:
return await loop.run_in_executor(None, self._send_recv, cmd)
# ── Public API ──────────────────────────────────────────────────────────
async def ping(self) -> bool:
"""Проверить связь с агентом."""
resp = await self._cmd({"cmd": "ping"})
return resp is not None and resp.get("ok") is True
async def relay_set(self, relay: int, state: bool) -> bool:
"""
Переключить реле.
:param relay: Номер реле 1..4 (в протоколе агента поле "ch").
:param state: True = ON, False = OFF.
:return: True при успехе.
"""
resp = await self._cmd({"cmd": "relay_set", "ch": relay, "state": state})
return resp is not None and resp.get("ok") is True
async def relay_get(self, relay: int) -> Optional[bool]:
"""
Прочитать состояние реле.
:return: True/False или None при ошибке.
"""
resp = await self._cmd({"cmd": "relay_get", "ch": relay})
if resp and resp.get("ok") is True:
return bool(resp.get("state"))
return None
async def can_send(self, can_id: int, data: list[int]) -> bool:
"""Отправить CAN-фрейм через M5."""
resp = await self._cmd({"cmd": "can_send", "id": can_id, "data": data})
return resp is not None and resp.get("ok") is True
async def can_recv(self, timeout_ms: int = 500) -> Optional[dict]:
"""
Принять CAN-фрейм.
:return: {"id": int, "data": list[int]} или None при таймауте/ошибке.
"""
resp = await self._cmd({"cmd": "can_recv", "timeout_ms": timeout_ms})
if resp and resp.get("ok") is True:
return {"id": resp["id"], "data": resp["data"]}
return None
@staticmethod
def find_port() -> Optional[str]:
"""Найти порт M5StampPLC. None если не найден."""
return _find_m5_port()

View file

@ -0,0 +1,136 @@
"""
models.py типы данных TUI сервисного инженера.
Все модели frozen dataclasses или IntEnum. Без бизнес-логики.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
class AppMode(Enum):
"""Режим работы приложения — определяется автодетектом USB."""
WAITING = auto() # ждём подключения устройства
FLASHING = auto() # обнаружен BootROM SDP (1FC9:0130)
DIAGNOSING = auto() # получен session_start по CDC
class TestStatus(Enum):
"""Статус выполнения теста."""
PENDING = auto()
RUNNING = auto()
PASS = auto()
FAIL = auto()
SKIP = auto()
class FlashTarget(Enum):
"""Что прошиваем."""
FIRMWARE_TEST = "firmware_test"
PRODUCTION = "production" # bootloader + tft_app
CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно
class FcbVariant(str, Enum):
"""Вариант FCB для кастомных бинарей.
W25Q128/W25Q64 (3-байтная адресация) и W25Q256/W25Q512 (4-байтная)
сведены к двум случаям см. обсуждение прошивки старых плат.
"""
W25Q128 = "w25q128"
W25Q512 = "w25q512"
@property
def fcb_filename(self) -> str:
"""Имя файла в tools/host/dcd/, соответствующее варианту."""
return f"{self.value}_fdcb.bin"
@property
def display_name(self) -> str:
return {
FcbVariant.W25Q128: "W25Q128 / W25Q64",
FcbVariant.W25Q512: "W25Q256 / W25Q512",
}[self]
@dataclass
class FlashPreset:
"""«Липкий» выбор оператора на FlashScreen.
Живёт в памяти ServiceApp (не на диске), переносится на следующую
плату в рамках одного запуска TUI чтобы не выбирать заново файл
и опции при прошивке партии одинаковых плат. Сбрасывается при
перезапуске TUI. Обновляется в момент нажатия «Загрузить» (не только
при успехе неудача чаще всего про USB-кабель, а не про то, что
выбор был неверным).
DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM.
"""
target: FlashTarget = FlashTarget.FIRMWARE_TEST
custom_bin_name: Optional[str] = None
use_dcd: bool = False
fcb_variant: FcbVariant = FcbVariant.W25Q128
@dataclass(frozen=True)
class TestInfo:
"""Метаданные теста из list_tests."""
id: str
name: str
critical: bool
requires_hil: bool
@dataclass
class TestResult:
"""Результат выполнения теста."""
id: str
status: TestStatus
duration_ms: int = 0
detail: str = ""
@dataclass
class SessionState:
"""Состояние текущей диагностической сессии."""
fw_version: str = ""
target: str = ""
chip_uid: str = ""
m5_connected: bool = False
tests: list[TestInfo] = field(default_factory=list)
results: dict[str, TestResult] = field(default_factory=dict)
def get_result(self, test_id: str) -> Optional[TestResult]:
return self.results.get(test_id)
def set_result(self, result: TestResult) -> None:
self.results[result.id] = result
@dataclass(frozen=True)
class ConfirmRequest:
"""confirm_request от таргета."""
id: str
prompt: str
timeout_ms: int
@dataclass(frozen=True)
class FlashProgress:
"""Прогресс прошивки."""
phase: str # "sdphost" | "blhost" | "done" | "error"
percent: int # 0..100
message: str

View file

@ -0,0 +1,404 @@
"""
orchestrator.py оркестратор confirm_request для TUI.
Получает события от FirmwareClient, принимает решение кто должен
ответить на confirm_request, и отправляет confirm обратно.
Три режима confirm:
standalone/interactive (display, mqs, usd):
TUI показывает prompt оператору + кнопки OK/FAIL + countdown
ждёт действия оператора
отправляет confirm
standalone/buttons:
TUI показывает инструкцию оператору
НЕ отправляет confirm таргет сам детектирует нажатие
ждёт следующего confirm_request или test_result
HIL (opto, can):
TUI автоматически командует M5 через M5Client
отправляет confirm без участия оператора
оператор видит только прогресс
Маршрутизация определяется по id confirm_request:
"opto_*" HIL (M5 relay)
"can_rx_ready" HIL (M5 can_send)
"can_tx_verify" HIL (M5 can_recv + verify)
"btn*" buttons (нет confirm, ждём test_result)
всё остальное operator (показать prompt)
Помимо confirm_request, протокол v2 определяет "progress" внутришаговые
информационные события долгих тестов (сейчас только usd: card_detect,
mount, write, read_compare см. docs/testing/PROTOCOL.md). Эти события
не требуют ответа, только отображаются как текущая фаза прогресса.
Публичный API:
Orchestrator.run_tests(test_ids) запустить тесты, yield OrchestratorEvent
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from enum import Enum, auto
from typing import AsyncGenerator, Callable, Coroutine, Optional
from .firmware_client import FirmwareClient
from .m5_client import M5Client
from .models import ConfirmRequest, TestResult, TestStatus
logger = logging.getLogger(__name__)
# Задержки для HIL
_RELAY_ON_S = 0.15
_RELAY_OFF_S = 0.50
# Карта confirm_id → реле M5 для opto-теста
# Формат: confirm_id → (relay_num, target_state)
_OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
"opto_in1_active": (3, True),
"opto_in1_inactive": (3, False),
"opto_in2_active": (4, True),
"opto_in2_inactive": (4, False),
"opto_rs_active": (2, True),
"opto_rs_inactive": (2, False),
}
# CAN параметры
_CAN_RX_ID = 0x100
_CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
_CAN_TX_ID = 0x200
_CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE]
# Тип события от FirmwareClient._recv_until() сигнализирующий таймаут чтения
_TIMEOUT_EVENT_TYPE = "_timeout"
# ── Типы событий оркестратора ────────────────────────────────────────────
class OrchestratorEventType(Enum):
TEST_BEGIN = auto() # тест начался
TEST_RESULT = auto() # тест завершился
TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.)
CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone)
CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически
BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm)
SUMMARY = auto() # итог всей сессии
ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту
@dataclass
class OrchestratorEvent:
"""Событие от оркестратора — передаётся в TUI."""
type: OrchestratorEventType
test_id: str = ""
test_name: str = ""
result: Optional[TestResult] = None
confirm: Optional[ConfirmRequest] = None
summary: Optional[dict] = None
message: str = ""
# Тип callback для ответа оператора
OperatorConfirmCallback = Callable[[bool], Coroutine]
class Orchestrator:
"""
Оркестратор confirm_request.
Пример::
orch = Orchestrator(firmware_client, m5_client)
async for event in orch.run_tests(["sdram", "opto", "display"]):
if event.type == OrchestratorEventType.CONFIRM_NEEDED:
confirmed = await tui.ask_operator(event.confirm)
await orch.resolve_operator_confirm(confirmed)
elif event.type == OrchestratorEventType.TEST_RESULT:
tui.update_result(event.result)
"""
def __init__(
self,
firmware: FirmwareClient,
m5: Optional[M5Client] = None,
) -> None:
self._fw = firmware
self._m5 = m5
self._operator_queue: asyncio.Queue[bool] = asyncio.Queue(maxsize=1)
async def resolve_operator_confirm(self, confirmed: bool) -> None:
"""
TUI вызывает этот метод когда оператор нажал OK или FAIL.
Разблокирует ожидание внутри run_tests().
"""
await self._operator_queue.put(confirmed)
async def run_tests(
self, test_ids: list[str]
) -> AsyncGenerator[OrchestratorEvent, None]:
"""
Запустить тесты и оркестрировать confirm_request.
Yields OrchestratorEvent в порядке поступления событий от firmware_test.
Блокируется на CONFIRM_NEEDED до вызова resolve_operator_confirm().
Гарантия для вызывающего кода: генератор ВСЕГДА заканчивается событием
SUMMARY либо настоящим (от firmware), либо синтетическим при обрыве
потока (таймаут чтения порта истёк раньше, чем пришёл summary).
Без этой гарантии TUI не может надёжно понять, что прогон завершился
(именно это вызывало зависание кнопок при таймауте см. отчёт).
"""
current_test_id = ""
got_summary = False
async for raw in self._fw.run_selected(test_ids):
event_type = raw.get("type", "")
if event_type == "test_begin":
current_test_id = raw.get("id", "")
yield OrchestratorEvent(
type=OrchestratorEventType.TEST_BEGIN,
test_id=current_test_id,
test_name=raw.get("name", ""),
)
elif event_type == "test_result":
status = {
"pass": TestStatus.PASS,
"fail": TestStatus.FAIL,
"skip": TestStatus.SKIP,
}.get(raw.get("status", "fail"), TestStatus.FAIL)
result = TestResult(
id=raw.get("id", ""),
status=status,
duration_ms=raw.get("ms", 0),
detail=raw.get("detail", ""),
)
current_test_id = ""
yield OrchestratorEvent(
type=OrchestratorEventType.TEST_RESULT,
test_id=result.id,
result=result,
)
elif event_type == "progress":
# Внутришаговый прогресс долгого теста (сейчас только usd).
# Не ошибка — информационное событие, не требует ответа.
step = raw.get("step", "")
pstat = raw.get("status", "")
yield OrchestratorEvent(
type=OrchestratorEventType.TEST_PROGRESS,
test_id=raw.get("test", current_test_id),
message=f"{step}: {pstat}" if step else "progress",
)
elif event_type == "confirm_request":
confirm = ConfirmRequest(
id=raw.get("id", ""),
prompt=raw.get("prompt", ""),
timeout_ms=raw.get("timeout_ms", 30000),
)
async for ev in self._handle_confirm(confirm):
yield ev
elif event_type == "summary":
got_summary = True
yield OrchestratorEvent(
type=OrchestratorEventType.SUMMARY,
summary=raw,
)
elif event_type == _TIMEOUT_EVENT_TYPE:
# Чтение порта оборвалось по таймауту раньше, чем пришёл
# summary. Завершаем текущий тест (если он был в RUNNING)
# синтетическим FAIL, чтобы таблица результатов не осталась
# с тестом, навечно подвисшим в "выполняется".
timeout_s = raw.get("timeout_s", 0)
logger.error(
"Test run aborted: read timeout after %.0fs, current_test=%r",
timeout_s,
current_test_id,
)
if current_test_id:
yield OrchestratorEvent(
type=OrchestratorEventType.TEST_RESULT,
test_id=current_test_id,
result=TestResult(
id=current_test_id,
status=TestStatus.FAIL,
duration_ms=0,
detail=f"таймаут связи ({timeout_s:.0f}с)",
),
)
yield OrchestratorEvent(
type=OrchestratorEventType.ERROR,
message=f"Связь с платой прервана (таймаут {timeout_s:.0f}с)",
)
# break, не return — нужно дойти до synthetic summary ниже
break
else:
# Неизвестный, но не критичный тип события — логируем для
# разработчика, не показываем оператору как ошибку (см.
# отчёт замечание №2: ранее "progress" ошибочно считался
# неизвестным событием до того как протокол был сверен).
logger.debug("Unhandled protocol event: %r", raw)
if not got_summary:
# Поток событий оборвался (таймаут или обрыв соединения) без
# настоящего summary от firmware — синтезируем его, чтобы
# вызывающий код (DiagScreen._run_worker) гарантированно вышел
# из ожидания и разблокировал кнопки/чекбоксы.
yield OrchestratorEvent(
type=OrchestratorEventType.SUMMARY,
summary={"overall": "fail", "passed": 0, "failed": 0, "aborted": True},
)
# ── Маршрутизация confirm ────────────────────────────────────────────
async def _handle_confirm(
self, confirm: ConfirmRequest
) -> AsyncGenerator[OrchestratorEvent, None]:
"""Определить тип confirm и обработать соответственно."""
cid = confirm.id
if cid in _OPTO_RELAY_MAP:
async for ev in self._handle_hil_opto(confirm):
yield ev
return
if cid == "can_rx_ready":
async for ev in self._handle_hil_can_rx(confirm):
yield ev
return
if cid == "can_tx_verify":
async for ev in self._handle_hil_can_tx(confirm):
yield ev
return
if cid.startswith("btn"):
yield OrchestratorEvent(
type=OrchestratorEventType.BUTTONS_PROMPT,
confirm=confirm,
)
return
async for ev in self._handle_operator_confirm(confirm):
yield ev
# ── HIL opto ────────────────────────────────────────────────────────
async def _handle_hil_opto(
self, confirm: ConfirmRequest
) -> AsyncGenerator[OrchestratorEvent, None]:
relay_num, relay_state = _OPTO_RELAY_MAP[confirm.id]
if self._m5 is None:
logger.error("HIL confirm без M5: %s", confirm.id)
await self._fw.send_confirm(confirm.id, False)
yield OrchestratorEvent(
type=OrchestratorEventType.ERROR,
message=f"M5 не подключён для HIL confirm: {confirm.id}",
)
return
settle_s = _RELAY_ON_S if relay_state else _RELAY_OFF_S
ok = await self._m5.relay_set(relay_num, relay_state)
await asyncio.sleep(settle_s)
await self._fw.send_confirm(confirm.id, ok)
yield OrchestratorEvent(
type=OrchestratorEventType.CONFIRM_RESOLVED,
test_id="opto",
message=f"RLY{relay_num} {'ON' if relay_state else 'OFF'}{confirm.id}",
)
# ── HIL CAN RX ──────────────────────────────────────────────────────
async def _handle_hil_can_rx(
self, confirm: ConfirmRequest
) -> AsyncGenerator[OrchestratorEvent, None]:
if self._m5 is None:
await self._fw.send_confirm(confirm.id, False)
yield OrchestratorEvent(
type=OrchestratorEventType.ERROR,
message="M5 не подключён для CAN RX confirm",
)
return
ok = await self._m5.can_send(_CAN_RX_ID, _CAN_RX_DATA)
await self._fw.send_confirm(confirm.id, ok)
yield OrchestratorEvent(
type=OrchestratorEventType.CONFIRM_RESOLVED,
test_id="can",
message=f"M5 CAN TX id=0x{_CAN_RX_ID:03X} data={_CAN_RX_DATA}",
)
# ── HIL CAN TX verify ───────────────────────────────────────────────
async def _handle_hil_can_tx(
self, confirm: ConfirmRequest
) -> AsyncGenerator[OrchestratorEvent, None]:
if self._m5 is None:
await self._fw.send_confirm(confirm.id, False)
yield OrchestratorEvent(
type=OrchestratorEventType.ERROR,
message="M5 не подключён для CAN TX verify",
)
return
frame = await self._m5.can_recv(timeout_ms=500)
verified = (
frame is not None
and frame["id"] == _CAN_TX_ID
and frame["data"] == _CAN_TX_DATA
)
await self._fw.send_confirm(confirm.id, verified)
msg = (
f"M5 CAN RX ok: id=0x{_CAN_TX_ID:03X}"
if verified
else "M5 CAN RX: фрейм не получен или не совпадает"
)
yield OrchestratorEvent(
type=OrchestratorEventType.CONFIRM_RESOLVED,
test_id="can",
message=msg,
)
# ── Operator confirm ─────────────────────────────────────────────────
async def _handle_operator_confirm(
self, confirm: ConfirmRequest
) -> AsyncGenerator[OrchestratorEvent, None]:
"""
Передать confirm оператору. Блокируется до resolve_operator_confirm().
TUI должен показать prompt и дать возможность ответить.
"""
while not self._operator_queue.empty():
self._operator_queue.get_nowait()
yield OrchestratorEvent(
type=OrchestratorEventType.CONFIRM_NEEDED,
confirm=confirm,
)
timeout_s = confirm.timeout_ms / 1000.0
try:
confirmed = await asyncio.wait_for(
self._operator_queue.get(),
timeout=timeout_s,
)
except asyncio.TimeoutError:
logger.warning("Operator confirm timeout: %s", confirm.id)
confirmed = False
await self._fw.send_confirm(confirm.id, confirmed)

View file

@ -0,0 +1,8 @@
"""screens — публичный экспорт экранов TUI."""
from .diag import DiagScreen
from .flash import FlashScreen
from .post_flash import PostFlashScreen
from .waiting import WaitingScreen
__all__ = ["WaitingScreen", "FlashScreen", "PostFlashScreen", "DiagScreen"]

View file

@ -0,0 +1,78 @@
"""
connection_watcher.py мониторинг USB-соединения для экранов FlashScreen
и DiagScreen.
Периодически (каждые _WATCH_INTERVAL_S) проверяет, виден ли таргет ещё
на шине USB. При потере соединения экран обязан немедленно прекратить
любое взаимодействие (кроме кнопки "Выйти") и вернуться на WaitingScreen.
Решение архитектурно: при разрыве сессия считается недостоверной и
не восстанавливается экран не пытается определить "вернулась ли та же
самая плата", просто стартует заново с нуля.
Используется как миксин: класс экрана наследует ConnectionWatcherMixin
вторым родителем после Screen, вызывает self._start_connection_watch(check_fn)
в on_mount(), и переопределяет _on_connection_lost() для специфичной
очистки экрана перед уходом на WaitingScreen.
"""
from __future__ import annotations
from typing import Callable
from textual.message import Message
from textual.timer import Timer
_WATCH_INTERVAL_S = 1.5
class ConnectionLost(Message):
"""
Соединение с платой потеряно во время нахождения на экране.
Экран должен прекратить взаимодействие и вернуться на WaitingScreen.
"""
class ConnectionWatcherMixin:
"""
Миксин периодической проверки USB-соединения.
Использование в экране::
class FlashScreen(Screen, ConnectionWatcherMixin):
def on_mount(self) -> None:
self._start_connection_watch(Flasher.detect_sdp)
def on_unmount(self) -> None:
self._stop_connection_watch()
"""
_connection_watch_timer: Timer | None = None
_connection_lost: bool = False
def _start_connection_watch(self, check_fn: Callable[[], bool]) -> None:
"""
Запустить периодическую проверку. check_fn должна вернуть True
пока устройство видно на шине (Flasher.detect_sdp / detect_cdc).
"""
self._connection_lost = False
self._connection_check_fn = check_fn
self._connection_watch_timer = self.set_interval( # type: ignore[attr-defined]
_WATCH_INTERVAL_S, self._check_connection
)
def _stop_connection_watch(self) -> None:
if self._connection_watch_timer is not None:
self._connection_watch_timer.stop()
self._connection_watch_timer = None
def _check_connection(self) -> None:
if self._connection_lost:
return
try:
still_present = self._connection_check_fn()
except Exception:
still_present = False
if not still_present:
self._connection_lost = True
self._stop_connection_watch()
self.post_message(ConnectionLost()) # type: ignore[attr-defined]

View file

@ -0,0 +1,334 @@
"""
diag/__init__.py экран диагностики (режим B).
DiagScreen координирует три виджета:
TestListPanel выбор тестов (левая колонка)
ResultsPanel результаты (правая колонка)
ConfirmPanel confirm_request оператора (нижняя панель)
и Orchestrator маршрутизатор confirm_request.
Мониторинг соединения (см. отчёт, замечание 4): пока тесты не запущены,
каждые 1.5с проверяется наличие CDC-порта на шине. Во время активного
прогона мониторинг приостановлен таймаут чтения порта внутри Orchestrator
уже детектирует обрыв связи надёжнее (видит реальную остановку потока
данных, а не просто исчезновение устройства из списка портов) и сам
формирует понятный результат для прерванного теста.
"""
from __future__ import annotations
import logging
from typing import Optional
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.screen import Screen
from textual.widgets import Button, Label, ProgressBar, Static
from ...firmware_client import FirmwareClient
from ...flasher import Flasher
from ...m5_client import M5Client
from ...models import SessionState
from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType
from ...widgets import AppFrame
from ..connection_watcher import ConnectionLost, ConnectionWatcherMixin
from .confirm_panel import ConfirmPanel
from .results import ResultsPanel
from .test_list import TestListPanel
logger = logging.getLogger(__name__)
class DiagScreen(Screen, ConnectionWatcherMixin):
"""
Экран диагностики.
Messages:
DiagDone() сессия завершена (плата отключена или summary получен)
"""
BINDINGS = [
Binding("escape", "go_back", "Отключиться"),
Binding("r", "run_all", "Все тесты"),
Binding("s", "run_selected", "Выбранные"),
]
class DiagDone(Message):
"""Сессия диагностики завершена."""
def __init__(self, reason: Optional[str] = None) -> None:
super().__init__()
self.reason = reason
def __init__(
self,
firmware: FirmwareClient,
m5: Optional[M5Client] = None,
fw_version: str = "",
**kwargs,
) -> None:
super().__init__(**kwargs)
self._fw = firmware
self._m5 = m5
self._orchestrator = Orchestrator(firmware, m5)
self._session = SessionState(fw_version=fw_version)
self._tests_running = False
def compose(self) -> ComposeResult:
with AppFrame(id="diag-frame"):
# Шапка — фиксированная высота 3
with Horizontal(id="diag-header"):
yield Static("fw: —", id="diag-header-fw")
yield Static("MCU ID: —", id="diag-header-uid")
yield Static("M5 Bench: —", id="diag-header-m5")
# Рабочая зона: список тестов + результаты — занимает всё
# оставшееся место (1fr), сама прокручивается при переполнении
with Horizontal(id="diag-main"):
yield TestListPanel(id="diag-test-list")
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, show_percentage=False
)
yield Label("", id="diag-progress-label")
# Кнопки запуска — фиксированная высота 3 (под border Button)
with Horizontal(id="diag-btn-row"):
yield Button(
"▶ Запустить выбранные тесты",
id="diag-btn-run-selected",
variant="primary",
disabled=True,
)
yield Button(
"▶▶ Запустить все тесты",
id="diag-btn-run-all",
variant="default",
disabled=True,
)
yield Button(
"✕ Выйти из приложения", id="diag-btn-quit", variant="default"
)
# Панель confirm — auto-высота, видна только когда есть запрос
yield ConfirmPanel(id="diag-confirm", classes="hidden")
def on_mount(self) -> None:
self._init_session()
self._start_connection_watch(self._check_cdc_present)
def on_unmount(self) -> None:
self._stop_connection_watch()
def _check_cdc_present(self) -> bool:
# Не считаем потерей соединения во время активного прогона —
# Orchestrator сам детектирует обрыв через таймаут чтения порта
# (надёжнее: видит остановку потока данных, не просто список USB).
if self._tests_running:
return True
return Flasher.detect_cdc()
@on(ConnectionLost)
def _on_connection_lost(self) -> None:
self.post_message(self.DiagDone(reason="Соединение с платой потеряно"))
# ── Инициализация сессии ─────────────────────────────────────────────────
@work(thread=False)
async def _init_session(self) -> None:
try:
tests = await self._fw.list_tests()
uid = await self._fw.get_uid()
except Exception as exc:
logger.error("Session init failed: %s", exc)
self.post_message(self.DiagDone(reason="Не удалось получить список тестов"))
return
self._session.tests = tests
self._session.chip_uid = uid
self._session.m5_connected = self._m5 is not None
self._update_header()
test_list = self.query_one("#diag-test-list", TestListPanel)
test_list.populate(tests, m5_connected=self._session.m5_connected)
self.query_one("#diag-results", ResultsPanel).populate(tests)
self._set_run_buttons(enabled=True)
def _update_header(self) -> None:
self.query_one("#diag-header-fw", Static).update(
f"fw: {self._session.fw_version or '?'}"
)
uid = self._session.chip_uid or ""
self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}")
m5_widget = self.query_one("#diag-header-m5", Static)
if self._session.m5_connected:
m5_widget.update("M5 Bench: ✓ подключён")
m5_widget.remove_class("m5-absent")
else:
m5_widget.update("M5 Bench: ✕ нет связи")
m5_widget.add_class("m5-absent")
# ── Кнопки ───────────────────────────────────────────────────────────────
@on(Button.Pressed, "#diag-btn-run-selected")
def _on_run_selected(self) -> None:
ids = self.query_one("#diag-test-list", TestListPanel).get_selected_ids()
if ids:
self._start_run(ids)
@on(Button.Pressed, "#diag-btn-run-all")
def _on_run_all(self) -> None:
ids = [t.id for t in self._session.tests]
if 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:
if not self._tests_running:
self.post_message(self.DiagDone())
def action_run_all(self) -> None:
self._on_run_all()
def action_run_selected(self) -> None:
self._on_run_selected()
# ── Confirm ───────────────────────────────────────────────────────────────
@on(ConfirmPanel.Confirmed)
def _on_confirmed(self, event: ConfirmPanel.Confirmed) -> None:
"""Оператор ответил — передать в оркестратор."""
self.app.call_later(
self._orchestrator.resolve_operator_confirm, event.confirmed
)
# ── Запуск тестов ────────────────────────────────────────────────────────
def _start_run(self, test_ids: list[str]) -> None:
results = self.query_one("#diag-results", ResultsPanel)
results.reset()
self._session.results.clear()
self._show_progress(True)
self._update_progress(0, len(test_ids), "")
self._set_run_buttons(enabled=False)
self.query_one("#diag-test-list", TestListPanel).set_enabled(False)
self._tests_running = True
self._run_worker(test_ids)
@work(exclusive=True, thread=False)
async def _run_worker(self, test_ids: list[str]) -> None:
total = len(test_ids)
done = 0
try:
async for event in self._orchestrator.run_tests(test_ids):
self._handle_event(event, total, done)
if event.type == OrchestratorEventType.TEST_RESULT:
done += 1
if event.type == OrchestratorEventType.SUMMARY:
break
except Exception as exc:
logger.error("run_worker error: %s", exc)
finally:
self._tests_running = False
self._set_run_buttons(enabled=True)
self.query_one("#diag-test-list", TestListPanel).set_enabled(True)
try:
self.query_one("#diag-confirm", ConfirmPanel).hide()
except NoMatches:
pass
def _handle_event(self, event: OrchestratorEvent, total: int, done: int) -> None:
results = self.query_one("#diag-results", ResultsPanel)
confirm = self.query_one("#diag-confirm", ConfirmPanel)
if event.type == OrchestratorEventType.TEST_BEGIN:
results.set_running(event.test_id)
self._update_progress(done, total, f"Тест: {event.test_id}")
elif event.type == OrchestratorEventType.TEST_RESULT:
assert event.result is not None
self._session.set_result(event.result)
results.set_result(event.result)
elif event.type == OrchestratorEventType.TEST_PROGRESS:
# Внутришаговый прогресс долгого теста (сейчас только usd):
# card_detect, mount, write, read_compare — см. PROTOCOL.md.
self._update_progress(
done, total, f"Тест: {event.test_id}{event.message}"
)
elif event.type == OrchestratorEventType.CONFIRM_NEEDED:
assert event.confirm is not None
confirm.show_operator(
prompt=event.confirm.prompt,
timeout_ms=event.confirm.timeout_ms,
)
elif event.type == OrchestratorEventType.CONFIRM_RESOLVED:
self._update_progress(done, total, f"M5 Bench: {event.message}")
elif event.type == OrchestratorEventType.BUTTONS_PROMPT:
assert event.confirm is not None
confirm.show_buttons_hint(event.confirm.prompt)
elif event.type == OrchestratorEventType.SUMMARY:
self._on_summary(event.summary or {})
elif event.type == OrchestratorEventType.ERROR:
self._update_progress(done, total, f"{event.message}")
def _on_summary(self, summary: dict) -> None:
aborted = summary.get("aborted", False)
if aborted:
self.query_one("#diag-progress-label", Label).update(
"⚠ Прогон прерван: связь с платой потеряна"
)
return
overall = summary.get("overall", "fail")
passed = summary.get("passed", 0)
failed = summary.get("failed", 0)
icon = "" if overall == "pass" else ""
self.query_one("#diag-progress-label", Label).update(
f"{icon} Итог: {passed} прошли, {failed} не прошли"
)
# ── Утилиты ───────────────────────────────────────────────────────────────
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:
pct = int(done / total * 100) if total else 0
self.query_one("#diag-progress-bar", ProgressBar).update(
total=100, progress=pct
)
self.query_one("#diag-progress-label", Label).update(msg)
def _set_run_buttons(self, enabled: bool) -> None:
self.query_one("#diag-btn-run-selected", Button).disabled = not enabled
self.query_one("#diag-btn-run-all", Button).disabled = not enabled

View file

@ -0,0 +1,135 @@
"""
confirm_panel.py виджет панели подтверждения оператора.
Показывается при confirm_request от firmware_test требующем ответа оператора.
Скрыт по умолчанию (CSS-класс 'hidden').
Messages:
ConfirmPanel.Confirmed(confirmed: bool) оператор нажал OK или Нет
"""
from __future__ import annotations
from textual import on
from textual.app import ComposeResult
from textual.css.query import NoMatches
from textual.message import Message
from textual.timer import Timer
from textual.widget import Widget
from textual.widgets import Button, Static
_TICK_S = 1.0
class ConfirmPanel(Widget):
"""
Панель подтверждения оператора.
Использование::
panel = ConfirmPanel()
panel.show_operator(prompt="Экран залит красным?", timeout_ms=30000)
# Слушать ConfirmPanel.Confirmed в родительском экране
"""
class Confirmed(Message):
"""Оператор ответил на confirm_request."""
def __init__(self, confirmed: bool) -> None:
super().__init__()
self.confirmed = confirmed
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._timer: Timer | None = None
self._remaining: int = 0
def compose(self) -> ComposeResult:
yield Static("", id="confirm-prompt")
yield Static("", id="confirm-countdown")
yield Button("✓ Да", id="confirm-btn-ok", variant="success")
yield Button("✗ Нет", id="confirm-btn-fail", variant="error")
# ── Public API ────────────────────────────────────────────────────────────
def show_operator(self, prompt: str, timeout_ms: int) -> None:
"""Показать панель с кнопками OK/Нет и countdown."""
self._remaining = timeout_ms // 1000
self._set_prompt(f"{prompt}")
self._set_countdown(self._remaining)
self._show_buttons(True)
self.remove_class("hidden")
self._start_timer()
def show_buttons_hint(self, prompt: str) -> None:
"""
Показать инструкцию для теста кнопок.
Без кнопок OK/Нет оператор только читает, нажимает физическую кнопку.
"""
self._stop_timer()
self._set_prompt(f"{prompt}")
self._set_countdown("")
self._show_buttons(False)
self.remove_class("hidden")
def hide(self) -> None:
"""Скрыть панель, остановить таймер."""
self._stop_timer()
self.add_class("hidden")
self._show_buttons(True) # восстановить на следующий раз
# ── Обработчики ───────────────────────────────────────────────────────────
@on(Button.Pressed, "#confirm-btn-ok")
def _on_ok(self) -> None:
self.hide()
self.post_message(self.Confirmed(confirmed=True))
@on(Button.Pressed, "#confirm-btn-fail")
def _on_fail(self) -> None:
self.hide()
self.post_message(self.Confirmed(confirmed=False))
# ── Таймер countdown ─────────────────────────────────────────────────────
def _start_timer(self) -> None:
self._stop_timer()
self._timer = self.set_interval(_TICK_S, self._tick)
def _stop_timer(self) -> None:
if self._timer is not None:
self._timer.stop()
self._timer = None
def _tick(self) -> None:
self._remaining -= 1
self._set_countdown(self._remaining)
if self._remaining <= 0:
self.hide()
self.post_message(self.Confirmed(confirmed=False))
# ── Утилиты ───────────────────────────────────────────────────────────────
def _set_prompt(self, text: str) -> None:
try:
self.query_one("#confirm-prompt", Static).update(text)
except NoMatches:
pass
def _set_countdown(self, value: int | str) -> None:
text = f"{value}с" if isinstance(value, int) and value > 0 else ""
try:
self.query_one("#confirm-countdown", Static).update(text)
except NoMatches:
pass
def _show_buttons(self, visible: bool) -> None:
for btn_id in ("#confirm-btn-ok", "#confirm-btn-fail"):
try:
btn = self.query_one(btn_id, Button)
if visible:
btn.remove_class("hidden")
else:
btn.add_class("hidden")
except NoMatches:
pass

View file

@ -0,0 +1,263 @@
"""
results.py виджет правой колонки DiagScreen.
DataTable с колонками: Тест | HIL | Статус | Время | Детали.
До первого запуска показывает empty-state placeholder вместо таблицы.
Сортировка: FAIL всегда наверху (естественно бросается в глаза),
внутри групп статусов исходный порядок реестра firmware_test.
Строка с FAIL дополнительно подсвечивается красным фоном целиком.
Колонка "Детали" переносит длинный текст на несколько строк внутри ячейки
(а не обрезает) высота строки для FAIL вычисляется по длине detail
относительно ширины колонки. Остальные статусы всегда однострочные.
Публичный API:
ResultsPanel.populate(tests) показать пустую таблицу (PENDING)
ResultsPanel.set_running(test_id) пометить тест как выполняющийся
ResultsPanel.set_result(result) показать финальный результат
ResultsPanel.reset() сбросить все строки в PENDING
"""
from __future__ import annotations
import math
from rich.style import Style
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Center, Middle
from textual.widget import Widget
from textual.widgets import DataTable, Static
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,
}
_STATUS_DISPLAY: dict[TestStatus, tuple[str, str]] = {
TestStatus.PASS: ("✓ PASS", "green"),
TestStatus.FAIL: ("✗ FAIL", "bold white"),
TestStatus.RUNNING: ("", "yellow"),
TestStatus.SKIP: ("SKIP", "grey50"),
TestStatus.PENDING: ("", "grey50"),
}
_FAIL_BG = "dark_red"
# Ширина колонки "Детали" в символах — должна совпадать с шириной,
# заданной явно в on_mount() через table.add_column("Детали", width=...).
# Используется для расчёта высоты строки под перенос текста.
_DETAIL_COL_WIDTH = 21
_MAX_ROW_HEIGHT = 4 # не даём одной FAIL-строке занять весь экран
class ResultsPanel(Widget):
"""Правая колонка DiagScreen — таблица результатов тестов."""
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:
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)
# add_columns() (множественное число) не принимает width — без явной
# ширины колонка "Детали" сжимается до длины заголовка ("Детали" = 6
# символов) и обрезает любой более длинный текст, даже однострочный.
# Используем add_column() по одной с явной шириной под каждую,
# рассчитанной под ResultsPanel { width: 70 } (см. app.tcss).
table.add_column("Тест", width=22)
table.add_column("M5 Bench", width=8)
table.add_column("Статус", width=8)
table.add_column("Время", width=6)
table.add_column("Детали", width=21)
# ── Public API ────────────────────────────────────────────────────────────
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]
if not tests:
self._show_empty(True)
return
for test in tests:
self._tests[test.id] = test
self._statuses[test.id] = TestStatus.PENDING
self._add_row(test, TestStatus.PENDING, duration_ms=0, detail="")
self._show_empty(False)
def set_running(self, test_id: str) -> None:
"""Пометить тест как выполняющийся."""
self._update_row(test_id, TestStatus.RUNNING, duration_ms=0, detail="")
def set_result(self, result: TestResult) -> None:
"""Показать финальный результат теста."""
detail = result.detail if result.status == TestStatus.FAIL else ""
self._update_row(result.id, result.status, result.duration_ms, detail)
def reset(self) -> None:
"""Сбросить все строки в PENDING (перед повторным запуском)."""
for test_id in list(self._tests.keys()):
self._update_row(test_id, TestStatus.PENDING, duration_ms=0, detail="")
# ── Internal: empty state ───────────────────────────────────────────────
def _show_empty(self, visible: bool) -> None:
empty = self.query_one("#results-empty")
table = self.query_one("#results-table", DataTable)
if visible:
empty.remove_class("hidden")
table.add_class("hidden")
else:
empty.add_class("hidden")
table.remove_class("hidden")
# ── Internal: строки таблицы ────────────────────────────────────────────
def _row_height(self, detail: str) -> int:
"""
Сколько строк нужно ячейке "Детали" под перенос текста.
1 строка по умолчанию; растёт пропорционально длине detail,
ограничено _MAX_ROW_HEIGHT чтобы один FAIL не съел весь экран
(очень длинный detail в этом случае обрежется лучше, чем
одна строка съедает половину видимой таблицы).
"""
if not detail:
return 1
needed = math.ceil(len(detail) / _DETAIL_COL_WIDTH)
return max(1, min(needed, _MAX_ROW_HEIGHT))
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, height=self._row_height(detail))
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
self._statuses[test_id] = status
table = self.query_one("#results-table", DataTable)
cells = self._row_cells(test, status, duration_ms, detail)
new_height = self._row_height(detail)
# DataTable не предоставляет публичный API для изменения высоты
# уже добавленной строки (update_cell меняет только содержимое).
# Когда нужная высота отличается от текущей (например, тест перешёл
# в FAIL с многострочным detail) — пересоздаём строку: remove + add.
# Иначе — точечный update_cell, дешевле и не теряет курсор/scroll.
current_height = table.get_row_height(test_id)
if current_height != new_height:
table.remove_row(test_id)
table.add_row(*cells, key=test_id, height=new_height)
else:
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)
self._resort(table)
def _row_cells(
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 "[-]"
# overflow="fold" — перенос по символам на границе ячейки вместо
# обрезания с многоточием (Textual default), detail виден целиком
# на нескольких строках, если высота строки это позволяет.
detail_cell = Text(detail, style=detail_style, overflow="fold")
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),
detail_cell,
)
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

@ -0,0 +1,130 @@
"""
test_list.py виджет списка тестов с чекбоксами.
Отображает тесты из list_tests, отмечает HIL-тесты,
серит недоступные (HIL без M5).
Публичный API:
TestListPanel.populate(tests, m5_connected) заполнить список
TestListPanel.get_selected_ids() список выбранных id
TestListPanel.set_enabled(enabled) блокировать во время прогона
"""
from __future__ import annotations
from textual import on
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widget import Widget
from textual.widgets import Button, Checkbox, Label
class TestListPanel(Widget):
"""Левая колонка DiagScreen — список тестов с чекбоксами."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# test_id → 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:
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 ────────────────────────────────────────────────────────────
def populate(self, tests: list, m5_connected: bool) -> None:
"""
Заполнить список тестами. Изначально все тесты НЕ выбраны
(сервисник выбирает явно что запускать) кроме HIL без M5,
которые всегда disabled.
:param tests: list[TestInfo]
:param m5_connected: True если M5StampPLC подключён
"""
# Удалить старые строки (кроме заголовка и select-row)
for child in list(self.children):
if child.id != "test-list-select-row" and not child.has_class(
"section-title"
):
child.remove()
self._checkboxes.clear()
self._hil_unavailable.clear()
rows = []
for test in tests:
hil_unavailable = test.requires_hil and not m5_connected
self._hil_unavailable[test.id] = hil_unavailable
cb = Checkbox(
test.name,
value=False,
disabled=hil_unavailable,
id=f"cb-{test.id}",
classes="-textual-compact",
)
self._checkboxes[test.id] = cb
hil_css = (
"test-row-hil-badge hil-disabled"
if hil_unavailable
else "test-row-hil-badge"
)
badge_text = "[HIL]" if test.requires_hil else ""
rows.append(
Horizontal(
cb,
Label(badge_text, classes=hil_css),
classes="test-row",
)
)
if rows:
self.mount_all(rows)
def get_selected_ids(self) -> list[str]:
"""Вернуть список id выбранных (checked + not disabled) тестов."""
return [
tid for tid, cb in self._checkboxes.items() if cb.value and not cb.disabled
]
def set_enabled(self, enabled: bool) -> None:
"""
Разрешить/запретить изменение чекбоксов во время прогона.
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
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

@ -0,0 +1,298 @@
"""
flash.py экран прошивки (режим A).
Активен когда обнаружен BootROM SDP (1FC9:0130).
Поддерживает три варианта прошивки и chip erase.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.screen import Screen
from textual.widgets import (
Button,
Label,
Log,
ProgressBar,
RadioButton,
RadioSet,
Select,
Switch,
)
from ..flasher import CUSTOM_BINARIES_DIR, Flasher
from ..models import FcbVariant, FlashPreset, FlashProgress, FlashTarget
from ..widgets import AppFrame
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
logger = logging.getLogger(__name__)
class FlashScreen(Screen, ConnectionWatcherMixin):
"""
Экран прошивки.
Messages:
FlashDone(success, target) прошивка завершена
Мониторинг соединения: пока плата не прошивается (self._flashing
== False), каждые 1.5с проверяется наличие BootROM SDP на шине.
Если плата физически отключена в простое сессия считается
недостоверной, экран сразу уходит на WaitingScreen (см. замечание
4 отчёта). Во время самой прошивки/erase мониторинг приостановлен
обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess.
"""
BINDINGS = [
Binding("escape", "go_back", "Назад"),
]
class FlashDone(Message):
def __init__(
self,
success: bool,
target: Optional[FlashTarget] = None,
preset: Optional[FlashPreset] = None,
) -> None:
super().__init__()
self.success = success
self.target = target
self.preset = preset
def __init__(self, preset: Optional[FlashPreset] = None, **kwargs) -> None:
super().__init__(**kwargs)
self._flasher = Flasher()
self._flashing = False
self._preset = preset or FlashPreset()
def compose(self) -> ComposeResult:
with AppFrame(id="flash-frame"):
yield Label(
"⚡ Загрузка прошивки на плату индикатора (режим BootROM)",
id="flash-title",
)
with Vertical(id="flash-target-group"):
yield Label("Выбор загружаемой прошивки", classes="section-title")
with RadioSet(id="flash-radio"):
yield RadioButton(
"Диагностическая прошивка (firmware_test)",
id="radio-fw-test",
value=self._preset.target == FlashTarget.FIRMWARE_TEST,
)
yield RadioButton(
"Серийная прошивка (bootloader + tft_app)",
id="radio-production",
value=self._preset.target == FlashTarget.PRODUCTION,
)
yield RadioButton(
"Другое",
id="radio-custom",
value=self._preset.target == FlashTarget.CUSTOM,
)
is_custom = self._preset.target == FlashTarget.CUSTOM
with Vertical(
id="flash-custom-group",
classes="" if is_custom else "hidden",
):
yield Label("Файл (custom_binaries/)", classes="section-title")
yield Select[str](
[], id="flash-custom-select", prompt="Выберите файл..."
)
yield Label("Память платы", classes="section-title")
yield Select[str](
[(v.display_name, v.value) for v in FcbVariant],
id="flash-fcb-select",
value=self._preset.fcb_variant.value,
allow_blank=False,
)
with Horizontal(id="flash-dcd-row"):
yield Switch(value=self._preset.use_dcd, id="flash-dcd-switch")
yield Label("Использует SDRAM (DCD)", classes="section-title")
with Horizontal(id="flash-btn-row"):
yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning")
yield Button("⚠ Очистить память", id="flash-btn-erase", variant="error")
yield Button(
"✕ Выйти из приложения", id="flash-btn-quit", variant="default"
)
yield ProgressBar(
id="flash-progress-bar",
show_eta=False,
show_percentage=False,
classes="hidden",
)
yield Log(id="flash-log", auto_scroll=True)
def on_mount(self) -> None:
self._start_connection_watch(self._check_sdp_present)
self._populate_custom_select()
def _populate_custom_select(self) -> None:
select = self.query_one("#flash-custom-select", Select)
names = [p.name for p in Flasher.list_custom_binaries()]
select.set_options([(name, name) for name in names])
if not names:
self._log(f"⚠ Пусто: {CUSTOM_BINARIES_DIR}")
return
if self._preset.custom_bin_name in names:
select.value = self._preset.custom_bin_name
def on_unmount(self) -> None:
self._stop_connection_watch()
def _check_sdp_present(self) -> bool:
# Не считаем потерей соединения, если идёт активная операция —
# flash_usb.py сам обработает реальный обрыв через subprocess.
if self._flashing:
return True
return Flasher.detect_sdp()
@on(ConnectionLost)
def _on_connection_lost(self) -> None:
self.post_message(self.FlashDone(success=False, target=None))
# ── Обработчики ───────────────────────────────────────────────────────────
@on(RadioSet.Changed, "#flash-radio")
def _on_radio_changed(self, event: RadioSet.Changed) -> None:
is_custom = event.pressed.id == "radio-custom"
group = self.query_one("#flash-custom-group")
if is_custom:
group.remove_class("hidden")
else:
group.add_class("hidden")
@on(Button.Pressed, "#flash-btn-flash")
def _on_flash_pressed(self) -> None:
if self._flashing:
return
target, bin_path = self._resolve_target()
if target is None:
self._log("⚠ Выберите файл в custom_binaries/")
return
if target == FlashTarget.CUSTOM:
preset = FlashPreset(
target=target,
custom_bin_name=bin_path.name,
use_dcd=self._current_use_dcd(),
fcb_variant=self._current_fcb_variant(),
)
else:
preset = FlashPreset(target=target)
self._do_flash(target, bin_path, preset)
@on(Button.Pressed, "#flash-btn-erase")
def _on_erase_pressed(self) -> None:
if not self._flashing:
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:
if not self._flashing:
self.post_message(self.FlashDone(success=False))
# ── Workers ───────────────────────────────────────────────────────────────
@work(exclusive=True, thread=False)
async def _do_flash(
self, target: FlashTarget, bin_path: Optional[Path], preset: FlashPreset
) -> None:
self._set_busy(True)
self._show_progress(True)
self._log(f"▶ Прошивка: {target.value}")
ok = await self._flasher.flash(
target=target,
bin_path=bin_path,
use_dcd=preset.use_dcd,
fcb_variant=preset.fcb_variant,
progress_cb=self._on_progress,
)
self._set_busy(False)
self._finish_progress(ok)
self._log("✅ Готово" if ok else "❌ Ошибка")
self.post_message(self.FlashDone(success=ok, target=target, preset=preset))
@work(exclusive=True, thread=False)
async def _do_erase(self) -> None:
self._set_busy(True)
self._show_progress(True)
self._log("⚠ Очистка памяти (~30 с)...")
ok = await self._flasher.erase_chip(progress_cb=self._on_progress)
self._set_busy(False)
self._finish_progress(ok)
self._log("✅ Очистка памяти завершена" if ok else "❌ Очистка памяти: ошибка")
# ── Вспомогательные ───────────────────────────────────────────────────────
def _resolve_target(self) -> tuple[Optional[FlashTarget], Optional[Path]]:
radio = self.query_one("#flash-radio", RadioSet)
pressed_id = radio.pressed_button.id if radio.pressed_button else None
if pressed_id == "radio-fw-test":
return FlashTarget.FIRMWARE_TEST, None
if pressed_id == "radio-production":
return FlashTarget.PRODUCTION, None
if pressed_id == "radio-custom":
name = self.query_one("#flash-custom-select", Select).value
if name is None or name is Select.BLANK:
return None, None
p = CUSTOM_BINARIES_DIR / str(name)
if not p.exists():
self._log(f"⚠ Файл не найден: {p}")
return None, None
return FlashTarget.CUSTOM, p
return None, None
def _current_fcb_variant(self) -> FcbVariant:
return FcbVariant(self.query_one("#flash-fcb-select", Select).value)
def _current_use_dcd(self) -> bool:
return self.query_one("#flash-dcd-switch", Switch).value
async def _on_progress(self, progress: FlashProgress) -> None:
bar = self.query_one("#flash-progress-bar", ProgressBar)
bar.update(total=100, progress=progress.percent)
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:
self._flashing = busy
self.query_one("#flash-btn-flash", 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:
try:
self.query_one("#flash-log", Log).write_line(msg)
except NoMatches:
pass

View file

@ -0,0 +1,92 @@
"""
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 Center, 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"):
with Center(id="post-flash-title-row"):
yield Label("✅ firmware_test успешно записан", id="post-flash-title")
with Center(id="post-flash-instruction-row"):
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

@ -0,0 +1,144 @@
"""
waiting.py экран ожидания подключения платы.
Опрашивает USB каждые _DETECT_INTERVAL_S секунд через Flasher.
При обнаружении SDP или CDC отправляет DeviceDetected message в App.
Принимает опциональный disconnect_reason короткое сообщение о причине
возврата на этот экран (например, "Соединение с платой потеряно"),
показывается несколько секунд поверх обычной подсказки, затем исчезает
само. Нужно, чтобы потеря USB во время прошивки/диагностики (см. отчёт,
замечание 4) не выглядела как необъяснимый скачок экрана сервисник
должен понимать, что это осознанное поведение, а не баг.
"""
from __future__ import annotations
import logging
import tomllib
from pathlib import Path
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Center
from textual.css.query import NoMatches
from textual.message import Message
from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import Static
from ..boot_art import LOGO_ART
from ..flasher import Flasher
from ..models import AppMode
from ..widgets import AppFrame
logger = logging.getLogger(__name__)
_DETECT_INTERVAL_S = 1.5
_SPIN_INTERVAL_S = 0.1
_REASON_DISPLAY_S = 4.0
_SPINNER_FRAMES = ["", "", "", "", "", "", "", "", "", ""]
def _read_app_version() -> str:
"""
Прочитать версию service-tui из pyproject.toml.
Не используем importlib.metadata проект не устанавливается как пакет
(tool.uv.package = false), метаданные могут отсутствовать. Читаем файл
напрямую через tomllib (stdlib, requires-python >= 3.11 уже задан).
"""
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
try:
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
return data.get("project", {}).get("version", "0.0.0")
except Exception as exc:
logger.warning("Не удалось прочитать версию из %s: %s", pyproject_path, exc)
return "0.0.0"
_APP_VERSION = _read_app_version()
class WaitingScreen(Screen):
"""
Экран ожидания.
Messages:
DeviceDetected(mode) плата обнаружена, mode: FLASHING | DIAGNOSING
"""
class DeviceDetected(Message):
"""Плата обнаружена."""
def __init__(self, mode: AppMode) -> None:
super().__init__()
self.mode = mode
def __init__(self, disconnect_reason: Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
self._disconnect_reason = disconnect_reason
self._spinner_idx: int = 0
self._detect_timer: Timer | None = None
self._spin_timer: Timer | None = None
self._reason_timer: Timer | None = None
def compose(self) -> ComposeResult:
with AppFrame(id="waiting-frame"):
yield Static(f"service_tool v{_APP_VERSION}", id="waiting-version")
with Center(id="waiting-logo-row"):
yield Static(LOGO_ART, id="waiting-logo-art")
yield Static("", id="waiting-reason", classes="hidden")
yield Static("Подключите плату индикатора к USB...", id="waiting-hint")
yield Static(_SPINNER_FRAMES[0], id="waiting-spinner")
def on_mount(self) -> None:
self._detect_timer = self.set_interval(_DETECT_INTERVAL_S, self._poll_usb)
self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin)
if self._disconnect_reason:
self._show_reason(self._disconnect_reason)
def on_unmount(self) -> None:
self._stop_timers()
# ── Internal ──────────────────────────────────────────────────────────────
def _show_reason(self, reason: str) -> None:
try:
self.query_one("#waiting-reason", Static).update(f"{reason}")
self.query_one("#waiting-reason").remove_class("hidden")
except NoMatches:
return
self._reason_timer = self.set_timer(_REASON_DISPLAY_S, self._hide_reason)
def _hide_reason(self) -> None:
try:
self.query_one("#waiting-reason").add_class("hidden")
except NoMatches:
pass
def _spin(self) -> None:
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
try:
self.query_one("#waiting-spinner", Static).update(
_SPINNER_FRAMES[self._spinner_idx]
)
except NoMatches:
pass
def _poll_usb(self) -> None:
if Flasher.detect_sdp():
self._stop_timers()
self.post_message(self.DeviceDetected(AppMode.FLASHING))
elif Flasher.detect_cdc():
self._stop_timers()
self.post_message(self.DeviceDetected(AppMode.DIAGNOSING))
def _stop_timers(self) -> None:
if self._detect_timer:
self._detect_timer.stop()
if self._spin_timer:
self._spin_timer.stop()
if self._reason_timer:
self._reason_timer.stop()

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;
}
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,570 @@
# service-tui — техническая архитектура
> Компонент: `tools/production/` — TUI сервисного инженера (диагностика и
> прошивка платы MIMXRT1052CVJ5B).
> Документ описывает внутреннее устройство: структуру модулей, протокол
> взаимодействия с firmware/M5, экранную архитектуру Textual, известные
> особенности фреймворка.
> Пользовательская документация (экраны, запуск, конфигурация,
> рабочие процессы сервисника) — в [README.md](README.md).
---
## 1. Структура проекта
```bash
tools/production/
├── main.py ← точка входа (10 строк)
├── pyproject.toml ← зависимости uv
├── uv.lock
├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
└── app/
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
├── app.tcss ← единый файл стилей для всех экранов
├── models.py ← все типы данных (dataclass/Enum)
├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── flasher.py ← subprocess-обёртка над tools/host/flash_usb.py
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
├── widgets/
│ ├── __init__.py
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
└── screens/
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата
├── flash.py ← FlashScreen — прошивка / chip erase
├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
└── diag/
├── __init__.py ← DiagScreen — координатор диагностики
├── test_list.py ← TestListPanel — чекбоксы тестов, Выбрать/Снять все
├── results.py ← ResultsPanel — DataTable результатов
└── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown
```
---
## 2. Концепция
```mermaid
graph LR
subgraph PC["Сервисный ПК"]
TUI["service-tui\n(Textual App)"]
subgraph app["app/"]
FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
M5["m5_client.py\nSerial JSON-lines, UTF-8"]
FL["flasher.py\nsubprocess + pyusb detect"]
OR["orchestrator.py\nconfirm/progress/timeout router"]
end
TUI --> FC & M5 & FL & OR
end
subgraph Board["Плата TFT (MIMXRT1052)"]
FW["firmware_test\n(USB CDC)"]
ROM["BootROM SDP\n(1FC9:0130)"]
end
subgraph HIL["HIL стенд (опционально)"]
M5HW["M5StampPLC\nRLY14 + CAN"]
end
subgraph Host["tools/host/"]
FU["flash_usb.py\nsdphost + blhost"]
end
FC <-->|"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL -->|"subprocess uv run"| FU
FU -->|"sdphost + blhost\nVID:PID 1FC9:0130"| ROM
M5 <-->|"JSON-lines\nSerial"| M5HW
M5HW -->|"RLY14"| Board
```
> **Детект USB:** `Flasher.detect_sdp()`/`detect_cdc()` используют `pyusb` как
> основной метод (BootROM SDP не создаёт serial-порт на macOS и невидим через
> `pyserial.list_ports`), с fallback на `serial.tools.list_ports` для CDC.
> M5StampPLC детектируется отдельно в `m5_client.py` тем же способом
> (`pyusb`, VID/PID из `.env` — см. раздел 5).
---
## 3. Диаграмма состояний приложения
Состояние определяется автодетектом USB и меняется динамически без
перезапуска TUI. При потере соединения сессия разрывается полностью — TUI не
пытается восстановить прежнее состояние, а стартует заново с `WaitingScreen`.
```mermaid
stateDiagram-v2
[*] --> WAITING : запуск TUI
WAITING --> FLASHING : VID:PID 1FC9:0130\n(BootROM SDP)
WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC
FLASHING --> POST_FLASH : firmware_test прошит успешно
FLASHING --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое
POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое
DIAGNOSING --> FLASHING: плата переведена в SDP (перемычка BOOT_MOD)
```
Состояния соответствуют `AppMode` в `models.py`; переключение экранов —
`ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на
сообщения `DeviceDetected`/`FlashDone`/`DiagDone`.
---
## 4. Обработка confirm_request
Маршрутизация реализована в `Orchestrator._handle_confirm()` по значению
`confirm_request.id`. Помимо confirm, протокол v2 определяет `progress`
внутришаговые информационные события долгих тестов (сейчас только `usd`), не
требующие ответа.
```mermaid
flowchart TD
EV["Событие от firmware_test"]
EV --> T{type}
T -->|"confirm_request"| R{confirm_request.id}
T -->|"progress"| PROG["TEST_PROGRESS\nотобразить фазу в прогресс-строке"]
T -->|"test_begin / test_result / summary"| STD["стандартная обработка"]
T -->|"_timeout (синтетическое,\nот FirmwareClient)"| TO["синтезировать FAIL\nдля зависшего теста\n+ гарантированный SUMMARY"]
T -->|"неизвестный тип"| LOG["logger.debug — НЕ ошибка,\nне показывается оператору"]
R -->|"opto_*"| HIL_OPTO["M5: relay_set → settle → send_confirm"]
R -->|"can_rx_ready"| HIL_CAN_RX["M5: can_send → send_confirm"]
R -->|"can_tx_verify"| HIL_CAN_TX["M5: can_recv → verify → send_confirm"]
R -->|"btn*"| BTN["show_buttons_hint, БЕЗ JSON-confirm"]
R -->|"остальное"| OP["show_operator + countdown\nждать resolve_operator_confirm()"]
```
| `confirm_request.id` | Кто отвечает | Реле M5 |
| ------------------------------- | ---------------------------- | ------- |
| `opto_in1_active` / `_inactive` | M5 авто | RLY3 |
| `opto_in2_active` / `_inactive` | M5 авто | RLY4 |
| `opto_rs_active` / `_inactive` | M5 авто | RLY2 |
| `can_rx_ready` | M5 авто (CAN TX) | — |
| `can_tx_verify` | M5 авто (CAN RX) | — |
| `btn*` | физика, без JSON-ответа | — |
| всё остальное | оператор, prompt + countdown | — |
**Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно
одним событием `SUMMARY` — настоящим от firmware или синтетическим
(`aborted: true`), если чтение порта оборвалось по таймауту. Без этой гарантии
зависший тест блокировал бы кнопки "Выйти" и повторного запуска навсегда
(исторический баг, см. `CHANGELOG.md`).
---
## 5. Протокол M5 agent (JSON-lines)
`m5_client.py` общается с `tools/hil/m5/agent.py` напрямую (не через
`tools/hil/` pytest-окружение — у HIL pytest свой собственный путь: порт берёт
из `.env` (`HIL_M5_PORT`) и не использует `M5Client`).
Актуальный формат ответа агента:
```json
{"ok": true, "id": 1, "data": [...]}
```
Значимые детали, зафиксированные по факту сверки с `agent.py` (`grep` по
обработчикам команд):
- Поле успеха — **`ok`** (bool), не `status`. Относится ко всем командам:
`ping`, `relay_set`, `relay_get`, `can_send`, `can_recv`.
- Ключ канала реле в `relay_set`/`relay_get` — **`ch`**, не `relay`.
- VID/PID детекта M5StampPLC настраиваются через `.env`:
`SERVICE_M5_VID`/`SERVICE_M5_PID` (см. README, раздел «Конфигурация»).
Рантайм-режим агента (MicroPython) отличается от ROM-режима ESP32-S3 по
PID — при детекте ориентироваться на `just host::m5-scan`, а не на
документацию, если она когда-либо разойдётся с кодом.
**Важно на будущее:** документация (`HIL_BENCH.md`/`HIL_HOW_TO.md`) местами не
успевает за изменениями `agent.py`. При любых будущих изменениях протокола
агента (новые команды, смена формата ответа) — сверяться напрямую через
`grep` по `tools/hil/m5/agent.py`, а не полагаться только на документацию.
---
## 6. Мониторинг соединения и разрыв сессии
`ConnectionWatcherMixin` (`screens/connection_watcher.py`) подключается к
`FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине.
- **На `FlashScreen`** — проверка приостановлена во время активной
прошивки/erase (обрыв обнаружит сам `flash_usb.py` subprocess).
- **На `DiagScreen`** — проверка приостановлена во время прогона тестов
(обрыв надёжнее детектирует таймаут чтения порта внутри `Orchestrator`, не
просто исчезновение устройства из списка).
- При срабатывании — `ConnectionLost` message → экран постит
`FlashDone(success=False, target=None)` / `DiagDone(reason=...)`
`ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает
на `WaitingScreen(disconnect_reason=...)`. `FlashDone` в этой ветке не несёт
`preset` — «липкий» выбор (см. §8) сохраняется отдельно, в момент нажатия
«Загрузить», а не при завершении прошивки.
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
продолжает обычный автодетект.
Архитектурное решение: **сессия никогда не восстанавливается** — после
разрыва TUI не пытается определить, вернулась ли та же плата, просто стартует
диагностику с нуля.
---
## 7. AppFrame — общий каркас экранов
`widgets/app_frame.py` — единый контейнер, который оборачивает содержимое
всех трёх основных экранов:
```css
AppFrame {
width: 100%;
height: 100%;
max-width: 112;
max-height: 35;
border: heavy $primary;
}
```
Решает две задачи:
1. **Визуальная консистентность** — одна и та же рамка на всех экранах.
2. **Устраняет краш Textual 8.x** при mouse drag
(`assert isinstance(content_widget.parent, Widget)`) — раньше `Screen` мог
выступать `content_widget` напрямую; с `AppFrame` между `Screen` и
контентом всегда есть валидный промежуточный `Widget`.
Адаптивный размер (`100%` с потолком `112×35`) — гарантирует, что элементы
управления (кнопки, таблицы) никогда не обрезаются на маленьком терминале и
не расползаются на огромном мониторе. Потолок подобран и подтверждён
визуально на скриншотах всех пяти экранов; нижняя граница по ширине
обоснована жёстко: `TestListPanel(width:38)` + `ResultsPanel(width:70)` рядом
на `DiagScreen` дают 108 + рамка = 110 — меньше сжимать уже нельзя.
---
## 8. Прошивка кастомных бинарников и «липкий» выбор (FlashPreset)
### 8.1 Проблема
Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются
`nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 —
для них auto-config Flashloader (`configure-memory 0xC0000007` →
`0xF000000F`, см. `HOW_TO_FLASH.md`) достаточен. Для сторонних/легаси
бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader
не документирован как надёжный для 4-байтной адресации, а сами бинарники
приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат,
что `inputImageFile` в `hab_*.yaml` до сборки). Решение — собирать HAB
на лету и писать FCB явно, а не полагаться на auto-config.
### 8.2 Модели (`models.py`)
```python
class FcbVariant(str, Enum):
W25Q128 = "w25q128" # 3-байтная адресация — auto-config работал бы,
W25Q512 = "w25q512" # но пишем явно и здесь, для единообразия пути
# W25Q64/W25Q256 сведены к этим двум случаям — см. обсуждение
@dataclass
class FlashPreset:
target: FlashTarget = FlashTarget.FIRMWARE_TEST
custom_bin_name: Optional[str] = None
use_dcd: bool = False
fcb_variant: FcbVariant = FcbVariant.W25Q128
```
`FlashPreset` — «липкий» выбор оператора, живёт в `ServiceApp._last_flash_preset`
(память процесса, не диск). Захватывается в `FlashScreen._on_flash_pressed()`
**в момент нажатия «Загрузить»**, не только при успехе — неудача чаще всего
про физическое соединение, а не про то, что выбор был неверным. Передаётся
в конструктор следующего `FlashScreen` через `FlashDone.preset`
`ServiceApp._on_flash_done()`. Решает конкретную задачу: прошивка партии
одинаковых плат подряд — вставил, TUI уже подставила прошлый выбор файла/
памяти/DCD, нажал «Загрузить», вынул, вставил следующую.
Рассматривался отдельный режим «массовое программирование» (авто-прошивка
по факту детекта SDP, без нажатия кнопки на каждую плату) — отклонён:
в SDP/Flashloader-режиме нет способа прочитать UID платы, авто-старт без
подтверждения оператора убирает последний шанс заметить, что в руках не та
плата. Оставлена только «липкая» память выбора (этот раздел).
### 8.3 Конвейер сборки (`flasher.py`)
```
Flasher.flash(target=CUSTOM, bin_path, use_dcd, fcb_variant, progress_cb)
└── _run_flash_custom()
├── _build_custom_hab(raw_bin, use_dcd, progress_cb)
│ ├── генерирует temp .yaml в tools/host/hab/ (по образцу hab_bootloader_*.yaml:
│ │ startAddress=0x60000000, ivtOffset=0x1000, initialLoadSize=0x2000,
│ │ family=mimxrt1050, + DCDFilePath: ../dcd/dcd.bin если use_dcd)
│ ├── uv run nxpimage hab export --force -c <yaml> -o <out>,
│ │ cwd=tools/host/hab/ (обязательно — relative DCDFilePath
│ │ резолвится от этой директории, как в build.just)
│ └── стриминг stdout nxpimage в progress_cb (не только logger.debug —
│ иначе во время сборки лог FlashScreen выглядит «зависшим»)
└── flash_usb.py --bin-path <hab_bin> --fcb-path tools/host/dcd/{fcb_variant}_fdcb.bin
(временный .yaml и собранный HAB-образ удаляются после прошивки)
```
`dcd/dcd.bin` — один и тот же файл независимо от проекта (SEMC/SDRAM-init не
зависит от того, что именно исполняется), поэтому просто константный путь,
без вариантов.
### 8.4 `flash_usb.py` — явная запись FCB вместо auto-config
```python
def write_fcb_explicit(fcb_path: Path) -> None:
"""write-memory 0x60000000 <fcb_path> — буквальная запись 512-байтного
FCB-блоба (tag 'FCFB'), а не magic option word 0xF000000F.
Обязателен для кастомных бинарей — auto-config Flashloader проверен
только для W25Q128."""
```
Активируется флагом `--fcb-path` (только вместе с `--bin-path`). Штатный
`--firmware`-путь (три сборки из `BUILD_DIR`) не тронут: без `--fcb-path`
поведение идентично тому, что было до этой доработки.
Заодно увеличен таймаут `blhost` для `flash-erase-all` (chip erase) —
`-t 200000` вместо дефолтного: W25Q512 стирается заметно дольше W25Q128,
дефолтного таймаута `blhost` не хватало. `flash-erase-region` (стирание
пары секторов под FCB+HAB при обычной прошивке) не трогали — там масштаб
на порядки меньше, дефолта достаточно независимо от чипа.
### 8.5 UI (`flash.py`)
При выборе радиокнопки «Другое» появляется `Vertical#flash-custom-group`:
`Select` по содержимому `custom_binaries/` (пересканируется в `on_mount()`),
`Select` по `FcbVariant`, `Switch` DCD. Выбор любой ДРУГОЙ радиокнопки в том
же `RadioSet` автоматически скрывает группу — отдельного «Назад» не
потребовалось, это штатное поведение взаимоисключающего `RadioSet`.
`#flash-target-group` ограничена `max-height: 18` с собственным скроллом —
без этого разросшаяся custom-группа (два `Select` + `Switch`) на маленьком
терминале выталкивала `#flash-log` почти до нулевой высоты. `#flash-log`
дополнительно защищён `min-height: 6` — лог гарантированно виден даже в
худшем случае.
---
## 9. Архитектура экранов
```mermaid
graph TB
subgraph ServiceApp["ServiceApp (app.py)"]
direction LR
WS["WaitingScreen"]
FS["FlashScreen"]
PF["PostFlashScreen"]
DS["DiagScreen"]
end
subgraph DiagInternals["DiagScreen (screens/diag/)"]
TL["TestListPanel\ntest_list.py"]
RP["ResultsPanel (DataTable)\nresults.py"]
CP["ConfirmPanel\nconfirm_panel.py"]
OR["Orchestrator\norchestrator.py"]
end
subgraph Clients["Клиенты"]
FC["FirmwareClient"]
M5["M5Client"]
FL["Flasher"]
end
WS -->|"DeviceDetected(FLASHING)"| FS
WS -->|"DeviceDetected(DIAGNOSING)"| DS
FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF
FS -->|"FlashDone(остальное)"| WS
PF -->|"Done"| WS
DS -->|"DiagDone(reason)"| WS
FS --> FL
DS --> OR
DS --> TL
DS --> RP
DS --> CP
CP -->|"Confirmed"| DS
OR --> FC
OR --> M5
WS --> FL
FS -.->|"ConnectionWatcherMixin"| FL
DS -.->|"ConnectionWatcherMixin"| FL
```
---
## 10. Жизненный цикл диагностической сессии
```mermaid
sequenceDiagram
participant OP as Оператор
participant TUI as ServiceApp
participant WS as WaitingScreen
participant DS as DiagScreen
participant FW as firmware_test
participant M5 as M5StampPLC
OP->>TUI: запустить service_tui
TUI->>WS: push_screen()
WS->>WS: pyusb poll каждые 1.5 с
OP->>FW: подключить плату USB
WS->>TUI: DeviceDetected(DIAGNOSING)
TUI->>FW: auto_connect() → ping→pong
TUI->>FW: get_version()
TUI->>M5: auto_connect() (опционально)
TUI->>DS: switch_screen(fw_version=...)
DS->>FW: list_tests() → TestInfo×N
DS->>FW: get_uid()
DS->>DS: populate (тесты НЕ выбраны по умолчанию)
DS->>DS: ConnectionWatcherMixin: старт мониторинга
OP->>DS: выбрать тесты / "Выбрать все" → Запустить
DS->>FW: run_selected([...])
loop Для каждого теста
FW-->>DS: test_begin
DS->>DS: ResultsPanel.set_running()
opt progress (напр. usd)
FW-->>DS: progress {step, status}
DS->>DS: обновить прогресс-строку
end
alt HIL confirm (opto / can)
FW-->>DS: confirm_request
DS->>M5: relay_set() / can_send() / can_recv()
DS->>FW: send_confirm(true/false)
else Оператор (display / mqs)
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_operator()
OP->>DS: OK / Нет
DS->>FW: send_confirm(true/false)
else Кнопки
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_buttons_hint()
OP->>FW: физическое нажатие
end
FW-->>DS: test_result
DS->>DS: ResultsPanel.set_result() + сортировка FAIL-наверх
end
FW-->>DS: summary
DS->>DS: показать итог PASS / FAIL
alt Нормальное завершение
OP->>DS: ESC / Выйти → DiagDone()
TUI->>WS: switch_screen()
else Потеря USB
DS->>DS: ConnectionLost
TUI->>FW: disconnect()
TUI->>WS: switch_screen(disconnect_reason=...)
end
```
---
## 11. Версионирование firmware
`firmware_test` версионируется через CMake
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
`configure_file`. Команда протокола `get_version` (по аналогии с `get_uid`)
запрашивается один раз при подключении в `ServiceApp._connect_and_diagnose()`
и передаётся в `DiagScreen` параметром конструктора — версия не запрашивается
повторно внутри самого экрана.
Версия самого TUI (`service_tool vX.Y.Z` на `WaitingScreen`) читается
отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из
`pyproject.toml` напрямую через `tomllib` (stdlib). `importlib.metadata`
сознательно не используется — проект не ставится как пакет
(`tool.uv.package = false`), метаданных может не быть.
---
## 12. Логотип (`boot_art.py`)
`LOGO_ART` — Rich-markup строка (29×21 символов, цвета `#3ca0dc` для синей
части логотипа, `white` для тёмной, `grey37` для фоновых точек), полученная
одноразовой coverage-based растеризацией `logo.png` (300×300 RGBA) через
Pillow: разбор на сетку символов с компенсацией аспекта шрифта терминала
(`ASPECT = 0.5`), классификация фона/двух цветовых групп логотипа по каналам,
плотность символа на ячейку — по доле непрозрачных пикселей
(`.::+*#`/`.::+%@`).
Сам скрипт растеризации **не сохранён в репозитории** (использовался
разово в песочнице, не входит в `pyproject.toml` TUI — новых
runtime-зависимостей `boot_art.py` не добавляет). Если логотип компании
сменится — скрипт нужно будет написать заново; логика воспроизводима (см.
абзац выше).
---
## 13. Известные грабли Textual 8.x
Зафиксировано на практике — экономит время при будущих доработках:
- **`Screen.Message` не существует.** Вложенные сообщения экранов
наследуются от `textual.message.Message` напрямую, не от несуществующего
атрибута `Screen.Message`.
- **`self._running` — зарезервированное имя.** `MessagePump` (предок
`Screen`) использует это поле для своего внутреннего message loop.
Случайное совпадение имени тихо ломает логику без исключения — в
`DiagScreen` переименовано в `_tests_running`.
- **`row.mount(child)` сразу после `self.mount(row)` бросает `MountError`** —
`row` ещё не прикреплён к DOM. Решение: передавать детей в конструктор
контейнера (`Horizontal(cb, label, classes=...)`) и монтировать одним
`mount_all()`.
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня
проекта — постоянно расходится при рефакторинге структуры. Решение: один
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает
ширину по умолчанию равную длине заголовка — длинный контент обрезается
независимо от `height` строки. Нужно использовать `add_column(label,
width=N)` по одной колонке.
- **`DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж значений
ячеек** (для указанных `columns`), не `row_key` и не `(row_key, row_data)`.
Сортировка по `test_id` напрямую невозможна без парсинга содержимого ячеек,
которые сами полностью контролируем.
- **Нет публичного API для изменения высоты уже добавленной строки.**
`update_cell()` меняет только содержимое. Если нужно изменить `height`
(например, под более длинный текст) — единственный надёжный путь:
`remove_row()` + `add_row(..., height=N)`.
- **Центровка текста внутри full-width виджета не решается `Center()`.**
`Static`/`Label` без `text-align`, растянутый на всю ширину родителя,
прижимает текст к левому краю — `Center()` вокруг такого виджета не
помогает (центрировать нечего, ребёнок и так 100% ширины). Нужен
`text-align: center` в CSS на самом элементе. Обратный случай — виджет с
"естественной" (auto) шириной — центрируется именно через `Center()`.
Важно не путать эти два случая (`#waiting-version` — первый случай,
`#post-flash-title`/`#post-flash-instruction` — второй).
---
## Известные открытые вопросы
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
форсирует Debug через `FIRMWARE_BUILD_TYPE`.
- **`tools/shared/m5_agent.py`** — сознательно не делался: pytest
HIL-окружение и TUI используют независимые M5-клиенты, признано правильным
архитектурным решением, а не техдолгом.
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
«копирование UID с экрана» — отложены, не начаты.
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту
детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем
понадобится полный батч-режим — потребуется отдельный предохранитель
(задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя
идентифицировать по UID.
- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили
не полагаться на него вообще, для кастомных бинарей FCB всегда пишется
явно (`--fcb-path`, см. §8.4). Остаётся не до конца понятым, работает ли
`configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос
снят с повестки архитектурным решением, а не исследован до конца.

View file

@ -0,0 +1,488 @@
# firmware_test — План разработки
> Версия: 0.8 | Обновлён после завершения Этапа 6г (bsp_mqs + test_mqs, hardware-verified).
---
## Контекст проекта
**Цель прошивки:** диагностика платы MIMXRT1052CVJ5B на сервисе (возврат по рекламации).
Запускается через BootROM (USB SDP), без предварительной прошивки загрузчика.
**Стенд:**
- Хост подключается через USB CDC ACM — единственный канал firmware_test
- HIL-тесты управляются через M5StampPLC (опционально)
- TUI-приложение оркестрирует и firmware_test (CDC), и M5 (Serial) одновременно
---
## Текущий статус
| Компонент | Статус | Примечание |
| ------------------------------ | ------ | ------------------------------------------------ |
| `bsp_usb_cdc` | ✅ | HIL тест пройден |
| firmware_test скелет | ✅ | `main.c` + `cli.c` |
| Протокол v2 + test_runner | ✅ | JSON-lines event-driven |
| `bsp_sdram` + `test_sdram` | ✅ | 4 фазы: addr/data/seq/retention |
| `bsp_qspi_flash` + `test_qspi` | ✅ | JEDEC + erase + rw + addr range |
| `bsp_sd` + `test_usd` | ✅ | bsp_sd + FatFS, pre_confirm, 4 шага |
| `bsp_display` + `test_display` | ✅ | 4 цвета + ротация, hardware-verified |
| `bsp_button` + `test_buttons` | ✅ | 2 кнопки, physical detect, hardware-verified |
| Протокол: `list_tests` | ✅ | Этап 6а, hardware-verified |
| Протокол: `run_selected` | ✅ | Этап 6а, hardware-verified |
| `test_opto` | ✅ | Этап 6б, hardware-verified |
| `test_can` | ✅ | Этап 6в, hardware-verified |
| HIL pytest firmware_cdc | ✅ | Этап 6д, `FirmwareCdc` + `firmware_cdc` фикстура |
| HIL pytest firmware_opto | ✅ | Этап 6е, `06_test_firmware_opto.py` |
| HIL pytest firmware_can | ✅ | Этап 6ж, `06_test_firmware_can.py` |
| `bsp_mqs` + `test_mqs` | ✅ | Этап 6г, hardware-verified |
| Provisioning | ⬜ | Этап 7 |
| TUI сервисного инженера | ⬜ | Этап 8 |
---
## Матрица тестов — итоговая
| ID | Название | Critical | HIL | Тип | BSP | Статус |
| --------- | ------------------ | -------- | --- | ----------- | ------------------ | ------ |
| `sdram` | SDRAM 32 MB | ✅ | ❌ | self | `bsp_sdram` ✅ | ✅ |
| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | self | `bsp_qspi_flash` ✅ | ✅ |
| `usd` | microSD (SDIO) | ❌ | ❌ | interactive | `bsp_sd` ✅ | ✅ |
| `display` | TFT Display RGB888 | ❌ | ❌ | interactive | `bsp_display` ✅ | ✅ |
| `buttons` | Test Buttons 1/2 | ❌ | ❌ | interactive | `bsp_button` ✅ | ✅ |
| `mqs` | MQS Audio Out | ❌ | ❌ | interactive | `bsp_mqs` ✅ | ✅ |
| `can` | CAN loopback | ❌ | ✅ | HIL | `bsp_can` ✅ | ✅ |
| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | HIL | `bsp_opto` ✅ | ✅ |
**Убранные тесты (закрытые решения):**
- `uart_ttl` — LPUART1 dev-инструмент (MCU-Link VCOM), в сервисе не используется
- `uart_iso` — RS_RX физически тот же пин что IN в `test_opto`, избыточно
---
## Закрытые архитектурные решения
> Не пересматривать без явного запроса.
### Этапы 15 (ранее зафиксированные)
- **Транспорт:** USB CDC ACM — единственный канал. UART не используется в firmware_test.
- **Парсинг JSON:** без cJSON, строковый `strstr`. Входящее поле всегда `"type"` / `"cmd"`.
- **SDRAM и DCD:** SEMC инициализируется DCD до `main()`. `bsp_sdram_init()` только верифицирует.
- **QSPI-функции в ITCM:** `AT_QUICKACCESS_SECTION_CODE` + `__STARTUP_INITIALIZE_RAMFUNCTION`.
- **W25Q256/512:** dedicated 4-byte opcodes, без Enter 4-Byte Mode (0xB7).
- **bsp_button_init():** вызывается в `init()` тест-модуля, не в `main.c`.
- **Тест дисплея:** 4 цвета + 2 ротации. Таймаут confirm 15 с → FAIL.
- **Тест кнопок:** физическая детекция через `bsp_button`. Хост не отправляет JSON confirm. Таймаут 10 с → SKIP.
### Этап 6 (новые решения)
- **Разделение тестов:** `requires_hil=false` (standalone) vs `requires_hil=true` (HIL).
TUI фильтрует HIL-тесты если M5StampPLC не подключён.
- **`list_tests`:** таргет отдаёт реестр тестов с метаданными по запросу хоста.
TUI строит UI динамически, не хардкодит список тестов.
- **`run_selected`:** запуск произвольного подмножества тестов по списку ID.
Порядок выполнения — как в реестре таргета, не как в запросе.
Таргет принимает любой список без проверки `requires_hil` — ответственность на TUI.
- **TUI оркестрирует M5:** firmware_test не знает про M5. При `confirm_request`
от HIL-теста TUI командует M5, получает результат, отправляет confirm.
- **M5 опционален:** TUI при старте пробует найти M5. Не нашёл — HIL-тесты
недоступны (серые в UI, не входят в `run_selected`).
- **Фильтрация HIL на стороне TUI:** таргет не фильтрует по `requires_hil`.
- **MQS стерео:** MQS MIMXRT1052 требует стерео PCM-буфер. На плате выведен
один канал. Буфер всегда стерео (L+R идентичны).
- **MQS тест:** захардкоженная мелодия ~4 с (A4 + E5, по 2 с каждая),
`confirm_request("mqs_tone")`, оператор слышит → PASS/FAIL.
`critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`.
- **MQS порядок init:** `bsp_mqs_amp_init()``bsp_delay(300)``bsp_mqs_init()`.
Усилитель запускается первым, чтобы успели зарядиться конденсаторы C103/C105 LM4875M.
Нарушение порядка приводит к щелчку при старте или отсутствию звука.
- **MQS USB keepalive:** воспроизведение через `bsp_mqs_play()` (async, не blocking),
параллельно крутится `bsp_usb_cdc_poll()`. Blocking-вариант голодает USB за ~4 с.
- **`pwmchannelenable` (NXP SDK ≥ 2.13):** поле в `pwm_signal_param_t` обязательно
выставлять в `true`. При инициализации через designated initializers без явного
указания равно `false``PWM_SetupPwm()` не выставляет `OUTEN` → ШИМ не выходит
на пин. Маскируется после отладочной сессии (отладчик оставляет `OUTEN` от прошлого
прогона). Воспроизводится только при cold reset.
- **ERRATA 50235 (FlexCAN + USB):** `FLEXCAN_Init()` содержит assert на
`CCM_CCGR5_CG12` (LPUART clock gate). После `bsp_usb_cdc_init()` gate
может быть закрыт → assert → HardFault. Workaround: `CLOCK_EnableClock(kCLOCK_Lpuart1)`
перед `FLEXCAN_Init()` внутри `bsp_can_init()`. Gate оставляется открытым —
закрывать не нужно, LPUART1 тактируется с минимальным потреблением.
`bsp_can_init()` вызывается из `main()` после `bsp_usb_cdc_init()`.
- **`firmware_cdc` фикстура:** не ждёт `session_start` (одноразовое событие при
старте, может быть пропущено). Проверяет живость через `ping → pong`.
- **`bsp_opto_force_read()`:** добавлен в BSP API для синхронного чтения пина
без дебаунса. Обновляет `confirmed_state`, сбрасывает `pending`. Используется
в `test_opto.c` после settle — обходит race condition когда чётное число ISR
при дребезге реле оставляет `pending=false` с устаревшим `confirmed_state`.
- **`bsp_opto_process()` в `test_opto.c`:** вызывается в settle loop после confirm,
но не в `test_runner_wait_confirm()`. Финальное чтение — через `bsp_opto_force_read()`.
- **Оркестратор оpto:** `RELAY_ON_S=0.15`, `RELAY_OFF_S=0.5` в `06_test_firmware_opto.py`.
Фиксированный sleep достаточен — реле переключается до отправки `confirmed:true`,
`bsp_opto_force_read()` читает финальное состояние пина напрямую.
### Этап 8 (TUI решения)
- **Прошивка — только USB SDP:** SWD недоступен сервиснику. spsdk (sdphost + blhost).
Оператор сам переставляет перемычку BOOT — это ок, документируется.
- **TUI автодетект:** определяет подключение по VID/PID — SDP BootROM (1FC9:0130)
или CDC firmware_test (session_start) — и показывает соответствующий экран.
- **Фреймворк TUI:** Textual (Python). Нативный async, реальные виджеты,
работает в SSH-сессии, вписывается в uv-экосистему.
- **tools/shared/m5_agent.py:** общая M5-логика, импортируется из `tools/hil/`
и `tools/production/`.
---
## Этап 6 — test_can + test_opto + test_mqs + протокол ✅ ЗАВЕРШЁН
### 6а — Расширение протокола ✅
**Файлы:** `protocol.h`, `protocol.c`, `cli.c`, `test_runner.c`, `PROTOCOL.md`
#### Новая команда `list_tests`
```json
→ {"type":"cmd","cmd":"list_tests"}
← {"type":"test_list","tests":[
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
{"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false},
{"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false},
{"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false},
{"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false},
{"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false},
{"id":"can","name":"CAN loopback","critical":false,"requires_hil":true},
{"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true}
]}
```
#### Новая команда `run_selected`
```json
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","qspi","display"]}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi",...}
← {"type":"test_begin","id":"display",...}
← {"type":"test_result","id":"display",...}
← {"type":"summary","passed":3,"failed":0,"skipped":0,"overall":"pass"}
```
Если хотя бы один ID не найден в реестре:
```json
← {"ok":false,"error":"UNKNOWN_TEST"}
```
**Реализация в `test_runner.c`:**
- Новый режим `RUNNER_MODE_SELECTED`
- Статический bool-массив `g_s_selected[REGISTRY_SIZE]` — без malloc
- `test_runner_run_selected(const char **pp_ids, size_t count)` — новая публичная функция
### 6б — test_opto.c ✅
**Файл:** `firmware/test/src/tests/test_opto.c`
6 шагов, попарно ACTIVE/INACTIVE для трёх каналов:
| Шаг | confirm_request id | M5 действие | Проверка |
| --- | ------------------- | ----------- | -------------------------------- |
| 1 | `opto_in1_active` | RLY3 ON | `bsp_opto_read(IN1) == ACTIVE` |
| 2 | `opto_in1_inactive` | RLY3 OFF | `bsp_opto_read(IN1) == INACTIVE` |
| 3 | `opto_in2_active` | RLY4 ON | `bsp_opto_read(IN2) == ACTIVE` |
| 4 | `opto_in2_inactive` | RLY4 OFF | `bsp_opto_read(IN2) == INACTIVE` |
| 5 | `opto_rs_active` | RLY2 ON | `bsp_opto_read(RS) == ACTIVE` |
| 6 | `opto_rs_inactive` | RLY2 OFF | `bsp_opto_read(RS) == INACTIVE` |
- Init: `bsp_opto_init()` единым вызовом для всех каналов
- Верификация синхронная после confirm (M5 переключил реле до отправки `confirmed:true`)
- FAIL при несоответствии: `detail = "<id> state mismatch: expected ACTIVE got INACTIVE"`
- Таймаут: `PROTOCOL_CONFIRM_TIMEOUT_MS` (30 с) на каждый шаг
### 6в — test_can.c ✅
**Файл:** `firmware/test/src/tests/test_can.c`
2 шага, оба направления независимо:
**Шаг 1 — RX (M5 → таргет):**
```bash
confirm_request("can_rx_ready")
→ TUI: M5.can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF])
→ TUI: confirm(true)
→ таргет: bsp_can_receive(&frame, 500 мс)
→ верификация: frame.id==0x100, frame.data==[0xDE,0xAD,0xBE,0xEF]
→ FAIL если timeout или несовпадение
```
**Шаг 2 — TX (таргет → M5):**
```bash
bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс)
confirm_request("can_tx_verify")
→ TUI: M5.can_recv(timeout=500 мс) → верификация id+data
→ TUI: confirm(true) если M5 принял корректно, confirm(false) если нет
→ FAIL если confirmed=false или timeout
```
- `disableSelfReception=true` — таргет не слышит свой TX, только M5 верифицирует
- Init: `bsp_can_init(&cfg)` + `bsp_can_accept_all()`
### 6г — bsp_mqs + test_mqs.c ✅
**Файлы:** `bsp/mqs/` + `firmware/test/src/tests/test_mqs.c`
**bsp_mqs:**
- SAI3 + eDMA (DMA0 канал 0) + MQS периферия
- Стерео PCM16 буфер (L+R идентичны), один физический выход `MQS_RIGHT`
- Усилитель LM4875M управляется PWM4 SM0 через RC-фильтр и буферный ОУ LM358
- API: `bsp_mqs_init/deinit`, `bsp_mqs_play/play_blocking`, `bsp_mqs_stop`,
`bsp_mqs_is_busy`, `bsp_mqs_amp_init/deinit`, `bsp_mqs_amp_set_volume`
**test_mqs:**
- Мелодия ~4 с: A4 (440 Гц) + E5 (659 Гц), по 2 с каждая, целочисленная LUT-синусоида
- Воспроизведение через `bsp_mqs_play()` (async) с `bsp_usb_cdc_poll()` в цикле
- `confirm_request("mqs_tone", "Do you hear a tone?", 15000)` → PASS/FAIL
- Порядок init: amp → delay 300 мс → mqs → build_melody (однократно, флаг)
- `critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`
### 6д — HIL pytest для firmware_test ✅
**Файлы:**
```
tools/hil/conftest.py ← фикстура firmware_cdc
tools/hil/06_test_firmware_opto.py
tools/hil/06_test_firmware_can.py
```
**Фикстура `firmware_cdc`:**
```python
@pytest.fixture(scope="module")
def firmware_cdc(m5):
"""
Открывает USB CDC порт firmware_test.
firmware_test уже прошит в Flash (не загружается pyOCD).
Проверяет живость через ping → pong.
"""
```
**`FirmwareCdcClient`** — тонкий клиент:
- `send_cmd(cmd_dict)` — отправить JSON команду
- `wait_event(type, timeout_s)` — ждать события нужного типа
- `confirm(id, ok)` — отправить `{"type":"confirm","id":"...","confirmed":true/false}`
- `run_test(id)` — запустить тест, вернуть test_result dict
**Justfile:**
```bash
hil-firmware-opto → pytest 06_test_firmware_opto.py -v
hil-firmware-can → pytest 06_test_firmware_can.py -v
```
---
## Этап 7 — Provisioning
### Что нужно
1. Читать `OCOTP_UNIQUE_ID` через SDK `fsl_ocotp`
2. Отправить `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary`
3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста
4. Записывать статус в Flash (первый сектор после прошивки, вне XIP)
### BSP (предварительно)
```c
/* bsp/provisioning/include/bsp/provisioning.h */
bsp_status_t bsp_prov_read_uid(uint8_t *p_uid, size_t len); /* 8 байт из OCOTP */
```
### Открытые вопросы — Этап 7
- [ ] Что именно записывать как «пройдено»: флаг в Flash или только отправить UID?
- [ ] Нужна ли защита от повторного provisioning (write-once)?
---
## Этап 8 — TUI сервисного инженера
### Стек технологий
| Компонент | Выбор | Обоснование |
| ------------- | ------------- | ----------------------------------------------------- |
| TUI фреймворк | **Textual** | Нативный async, виджеты, SSH-совместим, uv-экосистема |
| Serial | pyserial | Уже в стеке (tools/hil) |
| Прошивка | spsdk | sdphost + blhost, уже в tools/host |
| Конфигурация | python-dotenv | .env файл, совместим с существующим подходом |
### Структура приложения
```bash
tools/production/
├── pyproject.toml ← зависимости: textual, pyserial, spsdk, python-dotenv
├── uv.lock
├── main.py ← точка входа
├── app/
│ ├── tui.py ← Textual App, экраны, layout
│ ├── firmware_client.py ← USB CDC asyncio клиент firmware_test
│ ├── m5_client.py ← M5 Serial клиент (импортирует tools/shared/m5_agent.py)
│ ├── flasher.py ← USB SDP обёртка над spsdk
│ ├── orchestrator.py ← confirm_request → M5 action → confirm response
│ └── models.py ← TestInfo, TestResult, SessionState (dataclasses)
└── README.md
tools/shared/
└── m5_agent.py ← общая M5-логика для hil/ и production/
```
### Два режима работы
**Режим A — Прошивка** (триггер: VID/PID 1FC9:0130 обнаружен — BootROM SDP)
```
┌─ Прошивка платы ─────────────────────────────────┐
│ Обнаружен BootROM (SDP режим) │
│ │
│ Что прошить? │
│ ◉ firmware_test (диагностика) │
│ ○ Production (bootloader + tft_app) │
│ │
│ Файл: [/path/to/firmware_test_hab.bin ···] │
│ │
│ [ Прошить ] │
│ │
│ ████████████░░░░░░ 64% Запись во Flash... │
└────────────────────────────────────────────────────┘
```
**Режим B — Диагностика** (триггер: session_start получен по CDC)
```
┌─ Диагностика платы fw:0.1.0 ─────────────────────┐
│ M5StampPLC: ✓ подключён │ Плата: IMXRT1052 │
├────────────────────────────────────────────────────┤
│ Выбор тестов: │ Результаты: │
│ ☑ SDRAM 32 MB │ sdram ✓ PASS │
│ ☑ QSPI Flash │ qspi ✓ PASS │
│ ☑ microSD │ usd ✗ FAIL │
│ ☑ TFT Display │ mount failed: 5 │
│ ☑ Кнопки │ display ✓ PASS │
│ ☑ MQS Audio │ buttons ✓ PASS │
│ ☑ CAN loopback [HIL] │ mqs ✓ PASS │
│ ☑ Оптовходы [HIL] │ ... │
├────────────────────────────────────────────────────┤
│ [ Запустить выбранные ] [ Все тесты ] │
│ ████████████████░░░░ 80% Тест: display │
├────────────────────────────────────────────────────┤
│ ⚠ Экран залит красным цветом? │
│ [ ✓ Да ] [ ✗ Нет ] │
└────────────────────────────────────────────────────┘
```
### Поведение confirm_request в TUI
| Тип теста | Источник confirm | Действие TUI |
| -------------------- | ------------------ | --------------------------------------------- |
| standalone (display) | оператор | показать prompt, кнопки OK/FAIL, countdown |
| standalone (mqs) | оператор | показать prompt, кнопки OK/FAIL, countdown |
| standalone (buttons) | физическое нажатие | показать инструкцию, ждать test_result |
| HIL (opto, can) | оркестратор | auto: M5 action → confirm (оператор не видит) |
HIL confirm полностью автоматический — оператор видит только прогресс, не интерактивный prompt.
### Конфигурация (.env)
```ini
# Существующие переменные (tools/hil/.env):
HIL_VCOM_PORT=/dev/ttyACM0
HIL_M5_PORT=/dev/ttyACM1
# Новые переменные для production TUI:
SERVICE_CDC_PORT=AUTO # AUTO = автодетект по session_start
SERVICE_M5_PORT=AUTO # AUTO = автодетект, пусто = без M5
FIRMWARE_TEST_BIN=build/Release/firmware_test_hab.bin
PRODUCTION_BIN_BOOT=build/Release/bootloader_hab.bin
PRODUCTION_BIN_APP=build/Release/tft_app_hab.bin
```
### Запуск
```bash
just host::service-tui # запустить TUI сервисного инженера
just host::service-flash <bin> # прошить без TUI (для автоматизации)
```
### Процесс работы сервисника
**Диагностика (firmware_test уже в Flash):**
```bash
1. Плата в нормальном режиме (BOOT_MOD_1 → GND)
2. Подключить USB к сервисному ПК
3. just host::service-tui → TUI обнаружил session_start → Режим B
4. Выбрать тесты → Запустить → Смотреть результаты
```
**Перепрошивка (нужна новая версия firmware_test или production):**
```bash
1. Перемычка BOOT_MOD_1 → 3V3
2. Reset, подключить USB
3. TUI обнаружил 1FC9:0130 → Режим A
4. Выбрать бинарь → Прошить
5. Перемычка BOOT_MOD_1 → GND → Reset → TUI переходит в Режим B
```
---
## Порядок реализации
```
✅ Этап 1 протокол v2 + runner
✅ Этап 2 bsp_sdram + test_sdram
✅ Этап 3 bsp_qspi_flash + test_qspi
✅ Этап 4 bsp_sd + test_usd
✅ Этап 5 display + buttons
✅ Этап 6а протокол: list_tests + run_selected
✅ Этап 6б test_opto.c + hardware верификация
✅ Этап 6в test_can.c + hardware верификация
✅ Этап 6д HIL pytest: firmware_cdc фикстура (FirmwareCdc + firmware_cdc)
✅ Этап 6е HIL pytest: 06_test_firmware_opto.py
✅ Этап 6ж HIL pytest: 06_test_firmware_can.py
✅ Этап 6г bsp_mqs + test_mqs.c + hardware верификация
⬜ Этап 7 Provisioning (OCOTP UID + Flash-флаг) ← СЛЕДУЮЩИЙ ШАГ
⬜ Этап 8а tools/production/ скелет + models + clients
⬜ Этап 8б orchestrator + базовый Textual UI (список тестов, запуск, результаты)
⬜ Этап 8в Экран прошивки (flasher + SDP автодетект)
⬜ Этап 8г Provisioning в TUI
⬜ Этап 8д tools/shared/m5_agent.py (рефакторинг общей M5-логики)
⬜ Этап 9 Параллельно: обновить README + DEV_ARCH.md под финальную архитектуру
```
---
## Зависимости между этапами
```
✅ 6а (протокол) → ✅ 6б (opto) → ✅ 6в (can) → ✅ 6г (mqs)
✅ 6д (conftest) → ✅ 6е (opto pytest) → ✅ 6ж (can pytest)
⬜ 7 (provisioning)
⬜ 8 (TUI)
```

View file

@ -0,0 +1,8 @@
# TUI Service Tool Plan
1. Навести порядок с экранами "USB-POLL", "TRANSFER", возможно сделать bootlogo с версией программы и лого компании
2. Уменьшить размер активной области до предела и сделать его равным для всех экранов
3. Добавить обратный отчет в тесте buttons (таймаут)
4. Выгрузка результатов тестов в json файл с привязкой к UID микроконтроллера
5. Возможно ли копировать UID с экрана терминала?
6. Проверить работоспособность HIL тестов (CAN & Opto)

View file

@ -0,0 +1,121 @@
# Отчёт: разработка и стабилизация service-tui
**Период:** один рабочий тред, от architecture-планирования до прод-готовности
**Объект:** `tools/production/` — TUI-приложение для сервисного инженера (диагностика и прошивка платы MIMXRT1052CVJ5B)
---
## 1. Архитектура и первичная разработка (Этап 8)
Построено с нуля на Python + Textual, монорепо `tools/production/`:
```
tools/production/
├── main.py — точка входа (10 строк)
├── pyproject.toml — textual, pyserial, python-dotenv, pyinstaller, pyusb
└── app/
├── app.py — ServiceApp, роутинг экранов
├── app.tcss — единый файл стилей
├── models.py — AppMode, TestStatus, FlashTarget, TestInfo, TestResult, SessionState, ConfirmRequest, FlashProgress
├── firmware_client.py — async USB CDC клиент firmware_test
├── m5_client.py — async M5StampPLC клиент (HIL)
├── flasher.py — subprocess-обёртка над tools/host/flash_usb.py
├── orchestrator.py — маршрутизация confirm_request, обработка progress/timeout
├── widgets/
│ └── app_frame.py — общий адаптивный контейнер для всех экранов
└── screens/
├── waiting.py — WaitingScreen (USB autodetect)
├── flash.py — FlashScreen (прошивка/chip erase)
├── post_flash.py — PostFlashScreen (промпт смены BootMode)
├── connection_watcher.py — миксин мониторинга обрыва USB
└── diag/
├── __init__.py — DiagScreen (координатор)
├── test_list.py — TestListPanel (чекбоксы тестов)
├── results.py — ResultsPanel (DataTable результатов)
└── confirm_panel.py — ConfirmPanel (prompt + countdown)
```
### Ключевые архитектурные решения
- **Оркестрация confirm_request** по `id`: HIL (opto/CAN) → автоматически через M5, `btn*` → инструкция без JSON-ответа, остальное → оператор с countdown
- **Flasher** не дублирует spsdk-окружение — вызывает `tools/host/flash_usb.py` как subprocess
- **Standalone-сборка** через PyInstaller для сервисников без Python
- **Три режима прошивки**: firmware_test / Production / кастомный бинарь + chip erase
---
## 2. Расширения протокола и инструментария
| Что | Где |
| ------------------------------------------------------------------------------ | --------------------------------------------------------- |
| `--bin-path`, `--erase-chip` в `flash_usb.py` | `tools/host/flash_usb.py` |
| `get_version` команда + CMake-версионирование (`version.h.in`) | firmware (`protocol.c/h`, `cli.c`, `CMakeLists.txt`) |
| `FIRMWARE_BUILD_TYPE` из `.env` (Debug по умолчанию — Release пока нестабилен) | `flasher.py` |
| Поддержка кириллицы в названиях тестов/промптах | `firmware_client.py`, `m5_client.py` (UTF-8 вместо ASCII) |
---
## 3. Найденные и исправленные баги (хронологически)
### Линкер и сборка
- **`${PROJECT_SOURCE_DIR}``${CMAKE_SOURCE_DIR}`** в `firmware/test/CMakeLists.txt` — после добавления `project(VERSION)` путь к линкер-скрипту стал резолвиться неверно.
### USB-детект
- На macOS BootROM SDP (`1FC9:0130`) не создаёт serial-порт → невидим через `pyserial.list_ports`. Добавлен **pyusb** как primary метод детекта с fallback на `list_ports`.
### Textual-специфичные баги
- `Screen.Message` не существует в Textual 8.x → заменено на `from textual.message import Message`.
- `MountError` в `populate()``row.mount(child)` до прикрепления `row` к DOM → исправлено передачей детей в конструктор + `mount_all()`.
- `CSS_PATH` на каждом экране резолвился относительно файла класса и постоянно расходился с реальным расположением `.tcss`**унифицировано**: один `app.tcss` с `CSS_PATH` только на `ServiceApp`.
- **Краш при drag мыши** (`assert isinstance(content_widget.parent, Widget)`) — `Screen` выступал `content_widget` напрямую → решено введением общего `AppFrame`-контейнера между `Screen` и содержимым.
- **`self._running`** в `DiagScreen` случайно совпало с приватным полем `MessagePump._running` из самого Textual → кнопка "Выйти" была перманентно заблокирована. Переименовано в `_tests_running`.
- `DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж **значений ячеек**, не `row_key` — пришлось сортировать по содержимому ячейки "Статус", которое сами полностью контролируем.
- `table.add_columns()` (множественное число) не принимает `width=` → колонка "Детали" обрезалась по длине заголовка. Исправлено через `add_column()` по одной с явной шириной.
### Layout
- `AppFrame` с жёстким `width: 140; height: 44` обрезал контент на терминалах меньшего размера → сделан **адаптивным** (`width/height: 100%` с потолком `max-width: 160; max-height: 50`).
- `#diag-frame` на `layout: grid` с ручным расчётом `grid-rows` рассинхронизировался с реальным числом/высотой children (кнопки с `border: tall` не помещались в выделенную строку) → переход на `layout: vertical` с единственным `1fr` на рабочую зону.
- `#results-empty.hidden` не имел CSS-правила `display: none` → пустой контейнер с `height: 1fr` продолжал выталкивать таблицу результатов вниз даже будучи скрытым.
### Логика приложения
- **`TestListPanel.set_enabled()`** путал постоянное состояние "HIL без M5" с временной блокировкой на время прогона → чекбоксы навсегда залипали disabled после первого запуска. Исправлено отдельным словарём `_hil_unavailable`.
- **Таймаут чтения порта** (`_recv_until`) тихо завершался без сигнала → зависший тест навсегда оставался в RUNNING, кнопки "Выйти"/запуска блокировались навсегда. Теперь генератор **гарантированно** завершается одним `SUMMARY` (настоящим или синтетическим), зависший тест получает `FAIL` с понятным detail.
- **`progress`-событие** протокола (документированное в PROTOCOL.md, используется тестом USD) ошибочно считалось неизвестным/ошибочным → теперь явно обрабатывается как `TEST_PROGRESS`.
### Firmware (USD-тест)
- **USD зависает намертво на втором прогоне.** Причина: non-blocking USDHC host driver SDK оставался в состоянии "ожидание завершения транзакции" после `SD_HostDeinit()`, плюс структура `g_sd` не обнулялась между прогонами. Фикс: `USDHC_Reset(..., kUSDHC_ResetAll, ...)` + `memset(&g_sd, 0, ...)` в `bsp_sd_init()`/`bsp_sd_deinit()`.
---
## 4. UX-доработки (по согласованному плану итераций)
| Итерация | Что сделано |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Стабильность | `AppFrame`, скрытие ProgressBar в простое, кнопка "Выйти" на всех экранах |
| Workflow | `PostFlashScreen` (промпт смены BootMode после прошивки firmware_test — только для этого сценария), тесты изначально не выбраны + кнопки "Выбрать все"/"Снять все", полный UID в шапке |
| Polish | `ResultsPanel` переведён на `DataTable`: сортировка FAIL-наверх (стабильная внутри группы), подсветка FAIL-строки целиком, перенос длинных `detail` на несколько строк без обрезания, empty-state с подсказкой |
| Отчёт №2 | Убраны проценты в статус-баре (полоса осталась), мониторинг обрыва USB (`ConnectionWatcherMixin`) на `FlashScreen`/`DiagScreen` с разрывом сессии и понятным баннером причины на `WaitingScreen` |
---
## 5. Текущее состояние
**Готово и протестировано (headless):**
- Полный цикл: WaitingScreen → Flash/Diag → результат → возврат
- Прошивка (3 варианта) + chip erase + версионирование
- Диагностика: список тестов, выборочный/полный запуск, HIL через M5, кириллица
- Обработка обрывов: таймаут теста, потеря USB, повторные прогоны
- Адаптивная вёрстка на диапазоне терминалов 80×24 → 220×60
**Известные открытые вопросы / не доделано:**
- Release-сборка firmware нестабильна (медленное мигание — подозрение на проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно форсирует Debug через `FIRMWARE_BUILD_TYPE`
- `docs/DEV_ARCH.md`, `CHANGELOG.md`, `PLAN.md` — подготовлены диффы для финализации документации, но не применялись по твоему решению ("пока не буду обновлять документацию, нужно всё проверить")
- `tools/shared/m5_agent.py` — сознательно не делался: pytest HIL-окружение и TUI используют независимые M5-клиенты, признано правильным архитектурным решением, а не техдолгом
---
## 6. Рекомендации для следующего треда
1. Перед стартом — синхронизировать единую копию репозитория со всеми патчами из этого треда (было замечено расхождение версий файлов между чатом и локальной копией один раз, см. эпизод с TUI_REPORT.md).
2. Дальнейшее тестирование на реальном железе: полный цикл diagnostics с HIL (M5 подключён), повторные циклы прошивки/chip erase, граничные случаи USB-отключения во время разных операций.
3. Когда стабильность подтверждена — вернуться к обновлению `DEV_ARCH.md`/`CHANGELOG.md`/`PLAN.md` под финальную архитектуру.
4. Разобрать Release-сборку firmware (сравнить `hab_firmware_test_debug.yaml` vs `hab_firmware_test_release.yaml`).

View file

@ -0,0 +1,163 @@
# Отчёт: сессия доработки service-tui (продолжение)
**Период:** один рабочий тред, продолжение после `TUI_SESSION.md` (архитектура и стабилизация Этапа 8)
**Объект:** `tools/production/` — TUI сервисного инженера
**Вход в сессию:** `TUI_PLAN.md` (6 пунктов), реальный HIL-стенд (M5StampPLC подключён)
---
## 1. Пункт 6 — проверка HIL (CAN & Opto) ✅ ЗАКРЫТ
M5StampPLC физически был подключён, но `DiagScreen` показывал "M5 Bench: ✕ нет связи".
Три независимых бага в одном файле `tools/production/app/m5_client.py` — рассинхрон
между тем, что реально шлёт `tools/hil/m5/agent.py`, и тем, что ожидал клиент TUI.
HIL pytest (`06_test_firmware_opto.py`/`06_test_firmware_can.py`) эти баги не ловил,
потому что ходит по своему пути — порт берёт напрямую из `.env` (`HIL_M5_PORT`), а не
через VID/PID автодетект, и не использует `M5Client` вовсе.
| # | Баг | Было | Стало | Как нашли |
| --- | ------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | PID автодетекта M5 | `_M5_PID = 0x1001` (hardcode) | `_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)` | `just host::m5-scan` показал `303a:4001`, не `303a:1001``0x1001` это PID ROM-режима ESP32-S3, `0x4001` — PID рантайма с запущенным MicroPython/агентом |
| 2 | Поле успеха ответа агента | `resp.get("status") == "ok"` (везде: `ping`, `relay_set`, `relay_get`, `can_send`, `can_recv`) | `resp.get("ok") is True` | Сверка с `HIL_BENCH.md`/`HIL_HOW_TO.md` — агент отвечает `{"ok": true, ...}`, поля `"status"` в протоколе агента нет вообще |
| 3 | Ключ канала реле | `{"cmd": "relay_set", "relay": relay, ...}` | `{"cmd": "relay_set", "ch": relay, ...}``relay_get`) | Подтверждено `grep` по `tools/hil/m5/agent.py` — обработчик читает `cmd["ch"]`, ключа `"relay"` не существует |
`_M5_VID` заодно вынесен в `os.environ` по аналогии с `SERVICE_CDC_VID/PID` в `app.py`
(был раньше hardcode `0x303A`, поведение не поменялось, только стал переопределяемым).
`can_send`/`can_recv` сверены отдельно (`grep -A 12` по агенту) — багов не найдено,
формат ответа (`{"ok": true, "id": ..., "data": [...]}`) уже совпадал с ожиданиями клиента.
**Подтверждено на живом стенде:**
```
connected: True
ping: True
relay_set(3, True): True
relay_get(3): True
relay_set(3, False): True
```
Прогон `opto`/`can` через `service-tui` целиком (не только pytest) — пройден, о чём
сообщил заказчик по итогу. Пункт 6 закрыт.
---
## 2. Пункт 2 — единый размер активной области ✅ ЗАКРЫТ
**Файл:** `tools/production/app/app.tcss`, блок `AppFrame`.
```css
/* было */
max-width: 160;
max-height: 50;
/* стало */
max-width: 112;
max-height: 35;
```
Итоговый размер `112×35` подобран и подтверждён визуально пользователем на реальных
скриншотах всех пяти экранов (`WAITING`, `WAITING_AFTER_LOSS`, `FLASH`, `POST_FLASH`,
`TEST_SCREEN`). Нижняя граница по ширине обоснована жёстко: `TestListPanel(width:38)`
+ `ResultsPanel(width:70)` рядом на `DiagScreen` дают 108 + рамка = 110 — меньше сжимать
уже нельзя. `width/height: 100%` (адаптивность под маленькие терминалы) не тронуты —
менялся только потолок.
---
## 3. Пункт 3 — countdown в тесте buttons ❌ РЕШЕНО НЕ ДЕЛАТЬ
Разобрал возможную реализацию (передать `timeout_ms` в `ConfirmPanel.show_buttons_hint()`,
завести отдельный `auto_fail`-флаг в таймере, чтобы не дёргать случайный
`Confirmed`-message в ветке buttons, где хост не должен ничего отправлять). Заказчик
решил не усложнять рабочую логику ради UX-мелочи — пункт закрыт без изменений в коде.
---
## 4. Пункт 1 — bootlogo / логотип компании / центровка экранов ✅ ЗАКРЫТ
Изначально запрошенная "чистка лога USB-POLL/TRANSFER" (часть А) — отменена заказчиком,
не актуальна. Весь пункт свёлся к части Б: логотип + центровка.
### 4.1 Логотип — программная растеризация `logo.png`
Ручная отрисовка ASCII-арта отклонена как ненадёжная — вместо этого написан
одноразовый скрипт (Pillow, вне рантайма приложения) с coverage-based растеризацией:
1. Разбор `logo.png` (300×300 RGBA) на сетку символов с компенсацией соотношения
сторон шрифта терминала (`ASPECT = 0.5`).
2. Фон определялся не только по альфа-каналу (у файла альфа=0 только по внешним
углам), но и по близости цвета к белому (`r,g,b > 235`) — иначе весь непрозрачный
белый подложечный слой считался "закрашенным".
3. Классификация двух цветовых групп логотипа по каналам (`avgB - avgR > 15` → синий,
иначе тёмный/чёрный), калибровка порога — по гистограмме реальных цветов пикселей
(`(60,160,220)` синий кластер vs `(0,0,0)` чёрный кластер, чётко разделены).
4. Плотность символа на ячейку — по доле непрозрачных пикселей (`.::+*#` для синего,
`.::+%@` для тёмного), обрезка до bounding box контента → финальный размер 29×21.
**Результат вынесен в новый файл** `tools/production/app/boot_art.py` — константа
`LOGO_ART` (Rich-markup строка, цвета `#3ca0dc` для синей части, `white` для тёмной,
`grey37` для фоновых точек). Никаких новых рантайм-зависимостей (Pillow использовался
только в песочнице для генерации, не входит в `pyproject.toml` TUI).
### 4.2 Версия из `pyproject.toml`
`tools/production/app/screens/waiting.py` — функция `_read_app_version()`, читает
`[project].version` напрямую через `tomllib` (stdlib, `requires-python >= 3.11` уже
задан). `importlib.metadata` сознательно не использован — проект не ставится как пакет
(`tool.uv.package = false` в `pyproject.toml`), метаданных может не быть.
### 4.3 Финальная компоновка WaitingScreen
- Убран старый текст `"TFT Indicator Board\nService Tool"`.
- Добавлена строка `"service_tool vX.Y.Z"`**над** артом (не под), цвет `$warning`
(тот же оранжевый, что в заголовке `FlashScreen` — единообразие между экранами).
- `#waiting-frame`: `align: center middle``align: center top` (блок прижат к верху,
не висит по вертикальному центру).
- Баг центровки самого текста версии: `Static` без `text-align` растягивается на всю
ширину родителя и текст внутри прижимается к левому краю — обёртка `Center()` тут
не помогает (центрировать нечего, ребёнок и так 100% ширины). Фикс — `text-align:
center` в CSS на самом `#waiting-version`, а не контейнер-обёртка.
- Та же причина и тот же фикс повторно всплыли на `PostFlashScreen` для
`#post-flash-title` (`Label`) и `#post-flash-instruction` (`Static`, `width: auto`) —
здесь, наоборот, у элементов есть "естественная" ширина, поэтому раз уже `Center()`
вокруг них — верное решение (в отличие от `#waiting-version`, где элемент full-width).
Оба случая — одна и та же путаница между "центрировать full-width текст" (нужен
`text-align`) и "центрировать auto-width блок" (нужен `Center()`), просто с
противоположными по природе виджетами.
**Файлы, изменённые в рамках пункта 1:**
- `tools/production/app/boot_art.py` — новый, константа `LOGO_ART`
- `tools/production/app/screens/waiting.py``compose()`, `_read_app_version()`
- `tools/production/app/screens/post_flash.py``compose()` (обёртки `Center()`)
- `tools/production/app/app.tcss` — секции `WaitingScreen`, `PostFlashScreen`
---
## 5. Текущее состояние `TUI_PLAN.md`
| # | Пункт | Статус |
| --- | -------------------------------------------- | -------------------------------------------- |
| 1 | Экраны USB-POLL/TRANSFER, bootlogo | ✅ закрыт (часть А отменена, часть Б сделана) |
| 2 | Единый размер активной области | ✅ закрыт (112×35) |
| 3 | Countdown в тесте buttons | ❌ решено не делать |
| 4 | Экспорт результатов в JSON с привязкой к UID | ⏸ отложен |
| 5 | Копирование UID с экрана терминала | ⏸ отложен |
| 6 | Проверка HIL (CAN & Opto) | ✅ закрыт, 3 бага найдены и исправлены |
Пункты 4 и 5 сохранены в памяти для следующих сессий — не начаты, ждут возврата.
---
## 6. Рекомендации для следующего треда
1. Перед стартом пунктов 4/5 — обсудить формат JSON-экспорта (структура файла,
куда сохраняется, привязка к UID в имени/содержимом) и способ копирования UID
(зависит от поддержки OSC 52 в целевом терминале сервисника — стоит уточнить
заранее, SSH-сессии могут не поддерживать).
2. `m5_client.py` теперь соответствует реальному протоколу `agent.py` — при любых
будущих изменениях `agent.py` (новые команды, смена формата ответа) стоит сразу
сверяться через `grep` по агенту, а не полагаться на `HIL_BENCH.md`/`HIL_HOW_TO.md`
(документация местами не успевала за кодом — минимум один пример уже был найден).
3. `LOGO_ART` в `boot_art.py` — статичный артефакт. Если лого компании поменяется,
скрипт растеризации не сохранён в репозитории (был одноразовым в песочнице) —
при необходимости регенерации нужно будет написать заново (логика описана в
разделе 4.1 этого отчёта, воспроизводима).

42
tools/production/main.py Normal file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""main.py — точка входа service-tui."""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_ENV_FILE = _REPO_ROOT / ".env"
try:
from dotenv import load_dotenv
if _ENV_FILE.exists():
load_dotenv(_ENV_FILE)
except ImportError:
pass
def _setup_logging() -> None:
log_dir = Path(os.environ.get("SERVICE_LOG_DIR", str(Path(__file__).parent)))
log_file = log_dir / "service_tui.log"
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
handlers=[logging.FileHandler(log_file, encoding="utf-8")],
)
logging.getLogger("textual").setLevel(logging.WARNING)
def main() -> None:
_setup_logging()
from app.app import ServiceApp
ServiceApp().run()
if __name__ == "__main__":
sys.exit(main() or 0)

View file

@ -0,0 +1,22 @@
[project]
name = "service-tui"
version = "0.1.0"
description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
requires-python = ">=3.11"
dependencies = [
"textual>=0.80.0",
"pyserial>=3.5",
"python-dotenv>=1.0.0",
"pyinstaller>=6.0.0",
"pyusb>=1.0.0",
]
[project.scripts]
service-tui = "main:main"
[tool.uv]
package = false
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

264
tools/production/uv.lock Normal file
View file

@ -0,0 +1,264 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "platformdirs"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyinstaller"
version = "6.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" },
{ url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" },
{ url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" },
{ url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" },
{ url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" },
{ url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyusb"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "service-tui"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pyinstaller" },
{ name = "pyserial" },
{ name = "python-dotenv" },
{ name = "pyusb" },
{ name = "textual" },
]
[package.metadata]
requires-dist = [
{ name = "pyinstaller", specifier = ">=6.0.0" },
{ name = "pyserial", specifier = ">=3.5" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyusb", specifier = ">=1.0.0" },
{ name = "textual", specifier = ">=0.80.0" },
]
[[package]]
name = "setuptools"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
[[package]]
name = "textual"
version = "8.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]