diff --git a/.env.example b/.env.example index 7efa4d3..fd35c23 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -55,4 +58,7 @@ HIL_M5_TIMEOUT=3.0 # VCOM на плате таргета HIL_USB_CDC_PORT=/dev/cu.usbmodem11301 HIL_USB_CDC_BAUD=115200 -HIL_USB_CDC_TIMEOUT=5.0 \ No newline at end of file +HIL_USB_CDC_TIMEOUT=5.0 + +# Тип сборки firmware_test для TUI (Debug | Release) +FIRMWARE_BUILD_TYPE=Release \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36a74bd..342ec63 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ build* Testing/ !docs/testing !build.just +service_tui.log # Файлы настройки среды разработки .project diff --git a/.vscode/settings.json b/.vscode/settings.json index 2919f0d..2317133 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,4 +3,4 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "charliermarsh.ruff" } -} \ No newline at end of file +} diff --git a/bsp/sd/src/sd.c b/bsp/sd/src/sd.c index d2ecb45..05c5df2 100644 --- a/bsp/sd/src/sd.c +++ b/bsp/sd/src/sd.c @@ -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 /* 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; diff --git a/bsp/usb_cdc/src/usb_device_descriptor.h b/bsp/usb_cdc/src/usb_device_descriptor.h index c8a4dc8..b52e2a3 100644 --- a/bsp/usb_cdc/src/usb_device_descriptor.h +++ b/bsp/usb_cdc/src/usb_device_descriptor.h @@ -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) diff --git a/docs/DEV_ARCH.md b/docs/DEV_ARCH.md index 26975b4..61bd5f8 100644 --- a/docs/DEV_ARCH.md +++ b/docs/DEV_ARCH.md @@ -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_, uart_ │ ├── pyocd_utils.py ← FLEXRAM init, ELF loader, run_from_vectors diff --git a/docs/HOW_TO_FLASH.md b/docs/HOW_TO_FLASH.md index fa5c11e..d47c9cb 100644 --- a/docs/HOW_TO_FLASH.md +++ b/docs/HOW_TO_FLASH.md @@ -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. --- @@ -141,15 +160,19 @@ FCB-бинарник (`w25q128_fdcb.bin`) генерируется в NXP Secure ## Сравнение способов -| | USB SDP | SWD | -| ------------------------- | -------------------------------- | -------------------- | -| Переключение BOOT_MODE | Нужно | Не нужно | -| Power cycle после записи | Не нужен | **Обязателен** | -| FCB в образе | Не нужен (Flashloader пишет сам) | **Обязателен** | -| Скорость записи | ~50–100 kB/s | ~8–10 kB/s | -| Совместимость с отладкой | Раздельно | MCU-Link монопольный | -| Производственный сценарий | ✓ | — | -| Итеративная разработка | Неудобно (смена режима) | ✓ | +| | USB SDP | SWD | +| ------------------------- | ----------------------- | -------------------- | +| Переключение BOOT_MODE | Нужно | Не нужно | +| Power cycle после записи | Не нужен | **Обязателен** | +| FCB в образе | Не нужен для W25Q128* | **Обязателен** | +| Скорость записи | ~50–100 kB/s | ~8–10 kB/s | +| Совместимость с отладкой | Раздельно | MCU-Link монопольный | +| Производственный сценарий | ✓ | — | +| Итеративная разработка | Неудобно (смена режима) | ✓ | + +\* Flashloader пишет FCB сам через auto-config — проверено для W25Q128. +Для сторонних бинарников с другой памятью `service-tui` пишет FCB явно, +см. 1.5. --- diff --git a/firmware/test/CMakeLists.txt b/firmware/test/CMakeLists.txt index 798068e..6b7e74a 100644 --- a/firmware/test/CMakeLists.txt +++ b/firmware/test/CMakeLists.txt @@ -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}) diff --git a/firmware/test/src/cli.c b/firmware/test/src/cli.c index 738d58f..70ff248 100644 --- a/firmware/test/src/cli.c +++ b/firmware/test/src/cli.c @@ -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(); diff --git a/firmware/test/src/protocol.c b/firmware/test/src/protocol.c index a8db0c9..4fc3b62 100644 --- a/firmware/test/src/protocol.c +++ b/firmware/test/src/protocol.c @@ -157,4 +157,13 @@ void protocol_send_uid_response(const uint8_t *p_uid) (unsigned int) p_uid[3U], (unsigned int) p_uid[4U], (unsigned int) p_uid[5U], (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); } \ No newline at end of file diff --git a/firmware/test/src/protocol.h b/firmware/test/src/protocol.h index 0bbff09..b4c7988 100644 --- a/firmware/test/src/protocol.h +++ b/firmware/test/src/protocol.h @@ -19,12 +19,14 @@ #define PROTOCOL_H_ #include "test_module.h" +#include "version.h" #include #include #include + /** @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_ */ \ No newline at end of file diff --git a/firmware/test/src/tests/test_buttons.c b/firmware/test/src/tests/test_buttons.c index f3d865c..acdd6ef 100644 --- a/firmware/test/src/tests/test_buttons.c +++ b/firmware/test/src/tests/test_buttons.c @@ -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, diff --git a/firmware/test/src/tests/test_can.c b/firmware/test/src/tests/test_can.c index 2b62673..1ca7484 100644 --- a/firmware/test/src/tests/test_can.c +++ b/firmware/test/src/tests/test_can.c @@ -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, diff --git a/firmware/test/src/tests/test_display.c b/firmware/test/src/tests/test_display.c index a367f05..1a1a3ad 100644 --- a/firmware/test/src/tests/test_display.c +++ b/firmware/test/src/tests/test_display.c @@ -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, diff --git a/firmware/test/src/tests/test_mqs.c b/firmware/test/src/tests/test_mqs.c index 8a41979..67976a7 100644 --- a/firmware/test/src/tests/test_mqs.c +++ b/firmware/test/src/tests/test_mqs.c @@ -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, diff --git a/firmware/test/src/tests/test_opto.c b/firmware/test/src/tests/test_opto.c index 858a566..1b300b1 100644 --- a/firmware/test/src/tests/test_opto.c +++ b/firmware/test/src/tests/test_opto.c @@ -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, diff --git a/firmware/test/src/tests/test_qspi.c b/firmware/test/src/tests/test_qspi.c index 64c104c..7d0b91b 100644 --- a/firmware/test/src/tests/test_qspi.c +++ b/firmware/test/src/tests/test_qspi.c @@ -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, diff --git a/firmware/test/src/tests/test_sdram.c b/firmware/test/src/tests/test_sdram.c index 76ce69d..459f8de 100644 --- a/firmware/test/src/tests/test_sdram.c +++ b/firmware/test/src/tests/test_sdram.c @@ -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, diff --git a/firmware/test/src/tests/test_usd.c b/firmware/test/src/tests/test_usd.c index 6c3e4dc..2eeb7da 100644 --- a/firmware/test/src/tests/test_usd.c +++ b/firmware/test/src/tests/test_usd.c @@ -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, diff --git a/firmware/test/src/version.h.in b/firmware/test/src/version.h.in new file mode 100644 index 0000000..a80b12c --- /dev/null +++ b/firmware/test/src/version.h.in @@ -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_ */ \ No newline at end of file diff --git a/just/build.just b/just/build.just index d2d95c7..58f2f95 100644 --- a/just/build.just +++ b/just/build.just @@ -1,6 +1,6 @@ # ============================================================================= # scripts/build.just — сборка, тесты, HAB-образы -# Выполняется ТОЛЬКО внутри devcontainer (из терминала VSCode или tasks). +# Выполняется внутри devcontainer # # Рабочая директория — корень репозитория. # ============================================================================= diff --git a/just/host.just b/just/host.just index 7b3de76..a95193a 100644 --- a/just/host.just +++ b/just/host.just @@ -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" diff --git a/tools/host/README.md b/tools/host/README.md index 32fc649..40a326f 100644 --- a/tools/host/README.md +++ b/tools/host/README.md @@ -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. + --- ## Быстрый старт diff --git a/tools/host/flash_usb.py b/tools/host/flash_usb.py index f3263dd..76e8b6a 100644 --- a/tools/host/flash_usb.py +++ b/tools/host/flash_usb.py @@ -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,7 +311,10 @@ def flash(hab_bin: Path, ram_only: bool = False) -> None: ] ) - write_fcb() + 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(f" Образ: {hab_bin}") + 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__": diff --git a/tools/production/README.md b/tools/production/README.md new file mode 100644 index 0000000..395117a --- /dev/null +++ b/tools/production/README.md @@ -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 захватывает терминал. diff --git a/tools/production/app/app.py b/tools/production/app/app.py new file mode 100644 index 0000000..4adfe94 --- /dev/null +++ b/tools/production/app/app.py @@ -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 diff --git a/tools/production/app/app.tcss b/tools/production/app/app.tcss new file mode 100644 index 0000000..caaf5cb --- /dev/null +++ b/tools/production/app/app.tcss @@ -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; +} diff --git a/tools/production/app/boot_art.py b/tools/production/app/boot_art.py new file mode 100644 index 0000000..08d9e64 --- /dev/null +++ b/tools/production/app/boot_art.py @@ -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") diff --git a/tools/production/app/firmware_client.py b/tools/production/app/firmware_client.py new file mode 100644 index 0000000..aa01df0 --- /dev/null +++ b/tools/production/app/firmware_client.py @@ -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() — открыть порт, проверить ping→pong + 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) diff --git a/tools/production/app/flasher.py b/tools/production/app/flasher.py new file mode 100644 index 0000000..ef041c2 --- /dev/null +++ b/tools/production/app/flasher.py @@ -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()) diff --git a/tools/production/app/m5_client.py b/tools/production/app/m5_client.py new file mode 100644 index 0000000..cd0c4fe --- /dev/null +++ b/tools/production/app/m5_client.py @@ -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() diff --git a/tools/production/app/models.py b/tools/production/app/models.py new file mode 100644 index 0000000..cdf4a60 --- /dev/null +++ b/tools/production/app/models.py @@ -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 diff --git a/tools/production/app/orchestrator.py b/tools/production/app/orchestrator.py new file mode 100644 index 0000000..95a1d06 --- /dev/null +++ b/tools/production/app/orchestrator.py @@ -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) diff --git a/tools/production/app/screens/__init__.py b/tools/production/app/screens/__init__.py new file mode 100644 index 0000000..50c15c8 --- /dev/null +++ b/tools/production/app/screens/__init__.py @@ -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"] diff --git a/tools/production/app/screens/connection_watcher.py b/tools/production/app/screens/connection_watcher.py new file mode 100644 index 0000000..742fcfb --- /dev/null +++ b/tools/production/app/screens/connection_watcher.py @@ -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] diff --git a/tools/production/app/screens/diag/__init__.py b/tools/production/app/screens/diag/__init__.py new file mode 100644 index 0000000..27cc051 --- /dev/null +++ b/tools/production/app/screens/diag/__init__.py @@ -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 diff --git a/tools/production/app/screens/diag/confirm_panel.py b/tools/production/app/screens/diag/confirm_panel.py new file mode 100644 index 0000000..d694f43 --- /dev/null +++ b/tools/production/app/screens/diag/confirm_panel.py @@ -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 diff --git a/tools/production/app/screens/diag/results.py b/tools/production/app/screens/diag/results.py new file mode 100644 index 0000000..fadeecd --- /dev/null +++ b/tools/production/app/screens/diag/results.py @@ -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) diff --git a/tools/production/app/screens/diag/test_list.py b/tools/production/app/screens/diag/test_list.py new file mode 100644 index 0000000..068f8f9 --- /dev/null +++ b/tools/production/app/screens/diag/test_list.py @@ -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 diff --git a/tools/production/app/screens/flash.py b/tools/production/app/screens/flash.py new file mode 100644 index 0000000..9ba872b --- /dev/null +++ b/tools/production/app/screens/flash.py @@ -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 diff --git a/tools/production/app/screens/post_flash.py b/tools/production/app/screens/post_flash.py new file mode 100644 index 0000000..eaf6b87 --- /dev/null +++ b/tools/production/app/screens/post_flash.py @@ -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() diff --git a/tools/production/app/screens/waiting.py b/tools/production/app/screens/waiting.py new file mode 100644 index 0000000..e577de6 --- /dev/null +++ b/tools/production/app/screens/waiting.py @@ -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() diff --git a/tools/production/app/widgets/__init__.py b/tools/production/app/widgets/__init__.py new file mode 100644 index 0000000..9957b8c --- /dev/null +++ b/tools/production/app/widgets/__init__.py @@ -0,0 +1,5 @@ +"""widgets — общие переиспользуемые виджеты TUI.""" + +from .app_frame import AppFrame + +__all__ = ["AppFrame"] diff --git a/tools/production/app/widgets/app_frame.py b/tools/production/app/widgets/app_frame.py new file mode 100644 index 0000000..11e6bf6 --- /dev/null +++ b/tools/production/app/widgets/app_frame.py @@ -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; + } + """ diff --git a/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin b/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin new file mode 100644 index 0000000..336c7ae Binary files /dev/null and b/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin differ diff --git a/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin b/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin new file mode 100644 index 0000000..f5a4692 Binary files /dev/null and b/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin differ diff --git a/tools/production/docs/DEV_ARCH.md b/tools/production/docs/DEV_ARCH.md new file mode 100644 index 0000000..8e8563f --- /dev/null +++ b/tools/production/docs/DEV_ARCH.md @@ -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\nRLY1–4 + 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 -->|"RLY1–4"| 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 -o , + │ │ cwd=tools/host/hab/ (обязательно — relative DCDFilePath + │ │ резолвится от этой директории, как в build.just) + │ └── стриминг stdout nxpimage в progress_cb (не только logger.debug — + │ иначе во время сборки лог FlashScreen выглядит «зависшим») + └── flash_usb.py --bin-path --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 — буквальная запись 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` для этих чипов корректно в принципе — вопрос + снят с повестки архитектурным решением, а не исследован до конца. + \ No newline at end of file diff --git a/tools/production/docs/INITIAL_PLAN.md b/tools/production/docs/INITIAL_PLAN.md new file mode 100644 index 0000000..7fa9d46 --- /dev/null +++ b/tools/production/docs/INITIAL_PLAN.md @@ -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`, избыточно + +--- + +## Закрытые архитектурные решения + +> Не пересматривать без явного запроса. + +### Этапы 1–5 (ранее зафиксированные) + +- **Транспорт:** 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 = " 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 # прошить без 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) +``` \ No newline at end of file diff --git a/tools/production/docs/TUI_PLAN.md b/tools/production/docs/TUI_PLAN.md new file mode 100644 index 0000000..1463e00 --- /dev/null +++ b/tools/production/docs/TUI_PLAN.md @@ -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) diff --git a/tools/production/docs/TUI_SESSION_1.md b/tools/production/docs/TUI_SESSION_1.md new file mode 100644 index 0000000..a01ae9b --- /dev/null +++ b/tools/production/docs/TUI_SESSION_1.md @@ -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`). \ No newline at end of file diff --git a/tools/production/docs/TUI_SESSION_2.md b/tools/production/docs/TUI_SESSION_2.md new file mode 100644 index 0000000..d4f960d --- /dev/null +++ b/tools/production/docs/TUI_SESSION_2.md @@ -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 этого отчёта, воспроизводима). \ No newline at end of file diff --git a/tools/production/main.py b/tools/production/main.py new file mode 100644 index 0000000..10ec203 --- /dev/null +++ b/tools/production/main.py @@ -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) diff --git a/tools/production/pyproject.toml b/tools/production/pyproject.toml new file mode 100644 index 0000000..20b5d72 --- /dev/null +++ b/tools/production/pyproject.toml @@ -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" \ No newline at end of file diff --git a/tools/production/uv.lock b/tools/production/uv.lock new file mode 100644 index 0000000..b041381 --- /dev/null +++ b/tools/production/uv.lock @@ -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" }, +]