Merge branch 'feature-tui-python' into dev
This commit is contained in:
commit
477c0d6205
54 changed files with 5869 additions and 110 deletions
10
.env.example
10
.env.example
|
|
@ -13,6 +13,8 @@ BOOTROM_VID=1fc9
|
||||||
BOOTROM_PID=0130
|
BOOTROM_PID=0130
|
||||||
FLASHLOADER_VID=15a2
|
FLASHLOADER_VID=15a2
|
||||||
FLASHLOADER_PID=0073
|
FLASHLOADER_PID=0073
|
||||||
|
SERVICE_CDC_VID=1996
|
||||||
|
SERVICE_CDC_PID=00ad
|
||||||
|
|
||||||
# --- Paths ---
|
# --- Paths ---
|
||||||
# BUILD_DIR и TOOLS_DIR задаются абсолютно в корневом justfile
|
# BUILD_DIR и TOOLS_DIR задаются абсолютно в корневом justfile
|
||||||
|
|
@ -32,9 +34,10 @@ GDB_EXECUTABLE=arm-none-eabi-gdb
|
||||||
PYOCD_TARGET=mimxrt1050_quadspi
|
PYOCD_TARGET=mimxrt1050_quadspi
|
||||||
PYOCD_FREQUENCY=4000000
|
PYOCD_FREQUENCY=4000000
|
||||||
|
|
||||||
# FCB-бинарник для flash_swd.py (Flash Configuration Block, W25Q128 Quad SPI)
|
# FCB-бинарник для flash_swd.py (W25Q128 Quad SPI)
|
||||||
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
|
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
|
||||||
|
|
||||||
|
|
||||||
# --- HIL (аппаратный стенд) ---
|
# --- HIL (аппаратный стенд) ---
|
||||||
HIL_PYOCD_FREQUENCY=1000000
|
HIL_PYOCD_FREQUENCY=1000000
|
||||||
HIL_BUILD_DIR=build/target-debug
|
HIL_BUILD_DIR=build/target-debug
|
||||||
|
|
@ -55,4 +58,7 @@ HIL_M5_TIMEOUT=3.0
|
||||||
# VCOM на плате таргета
|
# VCOM на плате таргета
|
||||||
HIL_USB_CDC_PORT=/dev/cu.usbmodem11301
|
HIL_USB_CDC_PORT=/dev/cu.usbmodem11301
|
||||||
HIL_USB_CDC_BAUD=115200
|
HIL_USB_CDC_BAUD=115200
|
||||||
HIL_USB_CDC_TIMEOUT=5.0
|
HIL_USB_CDC_TIMEOUT=5.0
|
||||||
|
|
||||||
|
# Тип сборки firmware_test для TUI (Debug | Release)
|
||||||
|
FIRMWARE_BUILD_TYPE=Release
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,6 +59,7 @@ build*
|
||||||
Testing/
|
Testing/
|
||||||
!docs/testing
|
!docs/testing
|
||||||
!build.just
|
!build.just
|
||||||
|
service_tui.log
|
||||||
|
|
||||||
# Файлы настройки среды разработки
|
# Файлы настройки среды разработки
|
||||||
.project
|
.project
|
||||||
|
|
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -3,4 +3,4 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@
|
||||||
#include "bsp/sd.h"
|
#include "bsp/sd.h"
|
||||||
|
|
||||||
#include "fsl_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 "sdmmc_config.h" /* BOARD_SD_Config, BOARD_SDMMC_SD_HOST_BASEADDR */
|
||||||
|
|
||||||
|
#include <string.h> /* memset */
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
* Глобальный дескриптор карты — нужен SDK-стеку (передаётся по указателю
|
* Глобальный дескриптор карты — нужен SDK-стеку (передаётся по указателю
|
||||||
* в BOARD_SD_Config и sd_disk_initialize через g_sd).
|
* в BOARD_SD_Config и sd_disk_initialize через g_sd).
|
||||||
|
|
@ -51,6 +53,33 @@ bsp_status_t bsp_sd_init(void)
|
||||||
return BSP_OK;
|
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 */
|
ensure_host_configured(); /* только BOARD_SD_Config — заполняет g_sd */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -72,6 +101,14 @@ bsp_status_t bsp_sd_deinit(void)
|
||||||
SD_HostDeinit(&g_sd);
|
SD_HostDeinit(&g_sd);
|
||||||
SD_SetCardPower(&g_sd, false);
|
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_initialized = false;
|
||||||
g_s_host_configured = false;
|
g_s_host_configured = false;
|
||||||
return BSP_OK;
|
return BSP_OK;
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
#define USB_DEVICE_DEMO_BCD_VERSION (0x0101U)
|
#define USB_DEVICE_DEMO_BCD_VERSION (0x0101U)
|
||||||
|
|
||||||
/* ---- VID / PID --------------------------------------------------------- */
|
/* ---- VID / PID --------------------------------------------------------- */
|
||||||
#define USB_DEVICE_VID (0x1996U) /* TODO: заменить на свой */
|
#define USB_DEVICE_VID (0x1996U)
|
||||||
#define USB_DEVICE_PID (0x00ADU) /* TODO: заменить на свой */
|
#define USB_DEVICE_PID (0x00ADU)
|
||||||
|
|
||||||
/* ---- CDC коды классов -------------------------------------------------- */
|
/* ---- CDC коды классов -------------------------------------------------- */
|
||||||
#define CDC_COMM_CLASS (0x02U)
|
#define CDC_COMM_CLASS (0x02U)
|
||||||
|
|
|
||||||
|
|
@ -194,9 +194,14 @@ flowchart LR
|
||||||
│ │ ├── flash_usb.py ← USB SDP: sdphost + blhost
|
│ │ ├── flash_usb.py ← USB SDP: sdphost + blhost
|
||||||
│ │ ├── flash_swd.py ← SWD: FCB + HAB → pyOCD Flash
|
│ │ ├── flash_swd.py ← SWD: FCB + HAB → pyOCD Flash
|
||||||
│ │ ├── hab/ ← HAB yaml-конфиги (nxpimage)
|
│ │ ├── 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
|
│ │ └── uv.lock
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── production/ ← service-tui: TUI сервисного инженера (Textual)
|
||||||
|
│ │ прошивка/диагностика готовых плат, см.
|
||||||
|
│ │ tools/production/README.md + DEV_ARCH.md
|
||||||
|
│ │
|
||||||
│ └── hil/ ← HIL pytest-окружение
|
│ └── hil/ ← HIL pytest-окружение
|
||||||
│ ├── conftest.py ← фикстуры: m5, loaded_<n>, uart_<n>
|
│ ├── conftest.py ← фикстуры: m5, loaded_<n>, uart_<n>
|
||||||
│ ├── pyocd_utils.py ← FLEXRAM init, ELF loader, run_from_vectors
|
│ ├── pyocd_utils.py ← FLEXRAM init, ELF loader, run_from_vectors
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,26 @@ flowchart TD
|
||||||
```
|
```
|
||||||
|
|
||||||
ROM-загрузчик сам конфигурирует FlexSPI через DCD из HAB-образа, поэтому FCB
|
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 |
|
| | USB SDP | SWD |
|
||||||
| ------------------------- | -------------------------------- | -------------------- |
|
| ------------------------- | ----------------------- | -------------------- |
|
||||||
| Переключение BOOT_MODE | Нужно | Не нужно |
|
| Переключение BOOT_MODE | Нужно | Не нужно |
|
||||||
| Power cycle после записи | Не нужен | **Обязателен** |
|
| Power cycle после записи | Не нужен | **Обязателен** |
|
||||||
| FCB в образе | Не нужен (Flashloader пишет сам) | **Обязателен** |
|
| FCB в образе | Не нужен для W25Q128* | **Обязателен** |
|
||||||
| Скорость записи | ~50–100 kB/s | ~8–10 kB/s |
|
| Скорость записи | ~50–100 kB/s | ~8–10 kB/s |
|
||||||
| Совместимость с отладкой | Раздельно | MCU-Link монопольный |
|
| Совместимость с отладкой | Раздельно | MCU-Link монопольный |
|
||||||
| Производственный сценарий | ✓ | — |
|
| Производственный сценарий | ✓ | — |
|
||||||
| Итеративная разработка | Неудобно (смена режима) | ✓ |
|
| Итеративная разработка | Неудобно (смена режима) | ✓ |
|
||||||
|
|
||||||
|
\* Flashloader пишет FCB сам через auto-config — проверено для W25Q128.
|
||||||
|
Для сторонних бинарников с другой памятью `service-tui` пишет FCB явно,
|
||||||
|
см. 1.5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
# firmware/test/CMakeLists.txt Тестовая прошивка — входной контроль платы на
|
# 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)
|
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_subdirectory(fatfs)
|
||||||
|
|
||||||
add_executable(
|
add_executable(
|
||||||
|
|
@ -25,6 +34,8 @@ add_executable(
|
||||||
|
|
||||||
target_include_directories(firmware_test PRIVATE src/)
|
target_include_directories(firmware_test PRIVATE src/)
|
||||||
|
|
||||||
|
target_include_directories(firmware_test
|
||||||
|
PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||||
# __STARTUP_INITIALIZE_RAMFUNCTION - очистка секции .ram_function и копирование
|
# __STARTUP_INITIALIZE_RAMFUNCTION - очистка секции .ram_function и копирование
|
||||||
# туда данных из __ram_function_flash_start; __STARTUP_INITIALIZE_NONCACHEDATA -
|
# туда данных из __ram_function_flash_start; __STARTUP_INITIALIZE_NONCACHEDATA -
|
||||||
# инициализация некешируемое секции нулями
|
# инициализация некешируемое секции нулями
|
||||||
|
|
@ -73,7 +84,7 @@ target_link_options(
|
||||||
-Wl,-Map=${CMAKE_BINARY_DIR}/firmware_test.map
|
-Wl,-Map=${CMAKE_BINARY_DIR}/firmware_test.map
|
||||||
-Wl,--defsym=__stack_size__=0x2000
|
-Wl,--defsym=__stack_size__=0x2000
|
||||||
-Wl,--defsym=__heap_size__=0x2000
|
-Wl,--defsym=__heap_size__=0x2000
|
||||||
-T${PROJECT_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_flexspi_nor_sdram.ld)
|
-T${CMAKE_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_flexspi_nor_sdram.ld)
|
||||||
|
|
||||||
set_target_properties(${TARGET_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY
|
set_target_properties(${TARGET_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY
|
||||||
${CMAKE_BINARY_DIR})
|
${CMAKE_BINARY_DIR})
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,12 @@ static void handle_cmd(const char *p_line)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (strcmp(cmd_name, "get_version") == 0)
|
||||||
|
{
|
||||||
|
protocol_send_version_response();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (strcmp(cmd_name, "list_tests") == 0)
|
if (strcmp(cmd_name, "list_tests") == 0)
|
||||||
{
|
{
|
||||||
test_runner_send_list();
|
test_runner_send_list();
|
||||||
|
|
|
||||||
|
|
@ -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[3U], (unsigned int) p_uid[4U], (unsigned int) p_uid[5U],
|
||||||
(unsigned int) p_uid[6U], (unsigned int) p_uid[7U]);
|
(unsigned int) p_uid[6U], (unsigned int) p_uid[7U]);
|
||||||
cli_send(buf);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -19,12 +19,14 @@
|
||||||
#define PROTOCOL_H_
|
#define PROTOCOL_H_
|
||||||
|
|
||||||
#include "test_module.h"
|
#include "test_module.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
/** @brief Строка версии прошивки, вставляемая в session_start. */
|
/** @brief Строка версии прошивки, вставляемая в session_start. */
|
||||||
#define FIRMWARE_TEST_VERSION "0.1.4"
|
#define FIRMWARE_TEST_VERSION FIRMWARE_TEST_VERSION_STR
|
||||||
|
|
||||||
/** @brief Таймаут подтверждения по умолчанию, мс. */
|
/** @brief Таймаут подтверждения по умолчанию, мс. */
|
||||||
#define PROTOCOL_CONFIRM_TIMEOUT_MS 30000U
|
#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);
|
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_ */
|
#endif /* PROTOCOL_H_ */
|
||||||
|
|
@ -143,14 +143,14 @@ static void buttons_test_init(void)
|
||||||
*/
|
*/
|
||||||
static test_result_t buttons_test_run(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){
|
return (test_result_t){
|
||||||
|
|
@ -165,7 +165,7 @@ static test_result_t buttons_test_run(void)
|
||||||
/** @brief Дескриптор тест-модуля кнопок для реестра test_runner. */
|
/** @brief Дескриптор тест-модуля кнопок для реестра test_runner. */
|
||||||
const test_module_t K_TEST_BUTTONS = {
|
const test_module_t K_TEST_BUTTONS = {
|
||||||
.id = "buttons",
|
.id = "buttons",
|
||||||
.name = "Test Buttons",
|
.name = "Тактовые кнопки",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ static test_result_t can_run(void)
|
||||||
/** @brief Дескриптор для регистрации в test_runner. */
|
/** @brief Дескриптор для регистрации в test_runner. */
|
||||||
const test_module_t K_TEST_CAN = {
|
const test_module_t K_TEST_CAN = {
|
||||||
.id = "can",
|
.id = "can",
|
||||||
.name = "CAN loopback",
|
.name = "CAN шина",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = true,
|
.requires_hil = true,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
static bool step_rotation(test_result_t *p_out)
|
||||||
{
|
{
|
||||||
bool ok = step_rot_apply_and_confirm(BSP_DISPLAY_ROTATE_0, "display_rot0",
|
bool ok = step_rot_apply_and_confirm(BSP_DISPLAY_ROTATE_0, "display_rot0",
|
||||||
"Screen: left RED, right BLUE?", p_out);
|
"Дисплей: слева - красный, справа - синий?", p_out);
|
||||||
if (ok)
|
if (ok)
|
||||||
{
|
{
|
||||||
ok = step_rot_apply_and_confirm(BSP_DISPLAY_FLIP_HORIZONTAL, "display_rot_base",
|
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 в любом исходе */
|
/* Восстановить 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 } };
|
test_result_t fail_result = { .status = TEST_STATUS_FAIL, .duration_ms = 0U, .detail = { 0 } };
|
||||||
|
|
||||||
/* Этап 1: цвет */
|
/* Этап 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;
|
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;
|
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;
|
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;
|
return fail_result;
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +308,7 @@ static void display_test_deinit(void)
|
||||||
|
|
||||||
const test_module_t K_TEST_DISPLAY = {
|
const test_module_t K_TEST_DISPLAY = {
|
||||||
.id = "display",
|
.id = "display",
|
||||||
.name = "TFT Display RGB888",
|
.name = "TFT дисплей",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ static test_result_t test_mqs_run(void)
|
||||||
*/
|
*/
|
||||||
const confirm_params_t K_PARAMS = {
|
const confirm_params_t K_PARAMS = {
|
||||||
.id = "mqs_tone",
|
.id = "mqs_tone",
|
||||||
.prompt = "Do you hear a tone?",
|
.prompt = "Звук слышен?",
|
||||||
.timeout_ms = 15000U,
|
.timeout_ms = 15000U,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -200,7 +200,7 @@ static test_result_t test_mqs_run(void)
|
||||||
{
|
{
|
||||||
return (test_result_t){
|
return (test_result_t){
|
||||||
.status = TEST_STATUS_FAIL,
|
.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 = {
|
const test_module_t K_TEST_MQS = {
|
||||||
.id = "mqs",
|
.id = "mqs",
|
||||||
.name = "MQS Audio Out",
|
.name = "Аудио-выход",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ static test_result_t opto_run(void)
|
||||||
/** @brief Дескриптор для регистрации в test_runner. */
|
/** @brief Дескриптор для регистрации в test_runner. */
|
||||||
const test_module_t K_TEST_OPTO = {
|
const test_module_t K_TEST_OPTO = {
|
||||||
.id = "opto",
|
.id = "opto",
|
||||||
.name = "Opto Inputs",
|
.name = "Оптопары",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = true,
|
.requires_hil = true,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,7 @@ static void qspi_test_deinit(void)
|
||||||
|
|
||||||
const test_module_t K_TEST_QSPI = {
|
const test_module_t K_TEST_QSPI = {
|
||||||
.id = "qspi",
|
.id = "qspi",
|
||||||
.name = "QSPI Flash W25Qxx",
|
.name = "NOR Flash W25Qxx",
|
||||||
.critical = true,
|
.critical = true,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ static void sdram_test_deinit(void)
|
||||||
|
|
||||||
const test_module_t K_TEST_SDRAM = {
|
const test_module_t K_TEST_SDRAM = {
|
||||||
.id = "sdram",
|
.id = "sdram",
|
||||||
.name = "SDRAM 32 MB",
|
.name = "SDRAM MT48LC16x",
|
||||||
.critical = true,
|
.critical = true,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = NULL,
|
.pre_confirm_prompt = NULL,
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,10 @@ static void usd_deinit(void)
|
||||||
|
|
||||||
const test_module_t K_TEST_USD = {
|
const test_module_t K_TEST_USD = {
|
||||||
.id = "usd",
|
.id = "usd",
|
||||||
.name = "microSD (SDIO)",
|
.name = "microSD карта",
|
||||||
.critical = false,
|
.critical = false,
|
||||||
.requires_hil = false,
|
.requires_hil = false,
|
||||||
.pre_confirm_prompt = "Insert microSD card and press OK",
|
.pre_confirm_prompt = "Карта вставлена в слот?",
|
||||||
.init = usd_init,
|
.init = usd_init,
|
||||||
.run = usd_run,
|
.run = usd_run,
|
||||||
.deinit = usd_deinit,
|
.deinit = usd_deinit,
|
||||||
|
|
|
||||||
25
firmware/test/src/version.h.in
Normal file
25
firmware/test/src/version.h.in
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* @file version.h
|
||||||
|
* @brief Версия firmware_test — генерируется CMake из CMakeLists.txt.
|
||||||
|
*
|
||||||
|
* НЕ редактировать вручную. Версию менять в firmware/test/CMakeLists.txt:
|
||||||
|
* project(firmware_test VERSION X.Y.Z)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef VERSION_H_
|
||||||
|
#define VERSION_H_
|
||||||
|
|
||||||
|
/** @brief Мажорная версия. */
|
||||||
|
#define FIRMWARE_TEST_VERSION_MAJOR @firmware_test_VERSION_MAJOR@
|
||||||
|
|
||||||
|
/** @brief Минорная версия. */
|
||||||
|
#define FIRMWARE_TEST_VERSION_MINOR @firmware_test_VERSION_MINOR@
|
||||||
|
|
||||||
|
/** @brief Патч-версия. */
|
||||||
|
#define FIRMWARE_TEST_VERSION_PATCH @firmware_test_VERSION_PATCH@
|
||||||
|
|
||||||
|
/** @brief Версия строкой для протокола: "X.Y.Z". */
|
||||||
|
#define FIRMWARE_TEST_VERSION_STR \
|
||||||
|
"@firmware_test_VERSION_MAJOR@.@firmware_test_VERSION_MINOR@.@firmware_test_VERSION_PATCH@"
|
||||||
|
|
||||||
|
#endif /* VERSION_H_ */
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# scripts/build.just — сборка, тесты, HAB-образы
|
# scripts/build.just — сборка, тесты, HAB-образы
|
||||||
# Выполняется ТОЛЬКО внутри devcontainer (из терминала VSCode или tasks).
|
# Выполняется внутри devcontainer
|
||||||
#
|
#
|
||||||
# Рабочая директория — корень репозитория.
|
# Рабочая директория — корень репозитория.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ _venv := if _uname =~ "MINGW|MSYS|CYGWIN" { ".venv-host-win" } else { ".venv-hos
|
||||||
JUST_MIN := "1.36.0"
|
JUST_MIN := "1.36.0"
|
||||||
UV_MIN := "0.4.0"
|
UV_MIN := "0.4.0"
|
||||||
DOCKER_MIN := "24.0.0"
|
DOCKER_MIN := "24.0.0"
|
||||||
|
PLATFORM := os()
|
||||||
|
|
||||||
set working-directory := '..'
|
set working-directory := '..'
|
||||||
|
|
||||||
|
|
@ -31,14 +32,6 @@ check-deps:
|
||||||
fail() { echo -e " ${RED}❌ ${1}${RESET}"; ERRORS=$((ERRORS+1)); }
|
fail() { echo -e " ${RED}❌ ${1}${RESET}"; ERRORS=$((ERRORS+1)); }
|
||||||
warn() { echo -e " ${YELLOW}⚠️ ${1}${RESET}"; WARNINGS=$((WARNINGS+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() {
|
semver_ge() {
|
||||||
local a="$1" b="$2"
|
local a="$1" b="$2"
|
||||||
local a1 a2 a3 b1 b2 b3
|
local a1 a2 a3 b1 b2 b3
|
||||||
|
|
@ -56,7 +49,7 @@ check-deps:
|
||||||
|
|
||||||
install_hint() {
|
install_hint() {
|
||||||
local tool=$1
|
local tool=$1
|
||||||
case "${PLATFORM}" in
|
case "{{ PLATFORM }}" in
|
||||||
linux)
|
linux)
|
||||||
case "$tool" in
|
case "$tool" in
|
||||||
just) echo "uv tool install 'rust-just=={{ JUST_MIN }}'" ;;
|
just) echo "uv tool install 'rust-just=={{ JUST_MIN }}'" ;;
|
||||||
|
|
@ -100,7 +93,7 @@ check-deps:
|
||||||
}
|
}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD} Platform: ${PLATFORM}${RESET}"
|
echo -e "${BOLD} Platform: {{ PLATFORM }}${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD} Checking host dependencies...${RESET}"
|
echo -e "${BOLD} Checking host dependencies...${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -109,7 +102,7 @@ check-deps:
|
||||||
check_tool "uv" "uv" "{{ UV_MIN }}"
|
check_tool "uv" "uv" "{{ UV_MIN }}"
|
||||||
check_tool "docker" "docker" "{{ DOCKER_MIN }}"
|
check_tool "docker" "docker" "{{ DOCKER_MIN }}"
|
||||||
|
|
||||||
if [[ "${PLATFORM}" == "linux" ]]; then
|
if [[ "{{ PLATFORM }}" == "linux" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD} Checking udev rules (Linux only)...${RESET}"
|
echo -e "${BOLD} Checking udev rules (Linux only)...${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -182,22 +175,16 @@ setup-tools:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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 ""
|
||||||
echo " === Bootstrap: step 1/3 -- check-deps ==="
|
echo " === Bootstrap: step 1/3 -- check-deps ==="
|
||||||
just host::check-deps
|
just host::check-deps
|
||||||
if [[ "${PLATFORM}" == "linux" ]]; then
|
if [[ "{{ PLATFORM }}" == "linux" ]]; then
|
||||||
echo " === Bootstrap: step 2/3 -- setup-udev ==="
|
echo " === Bootstrap: step 2/3 -- setup-udev ==="
|
||||||
just host::setup-udev
|
just host::setup-udev
|
||||||
just host::setup-m5-udev
|
just host::setup-m5-udev
|
||||||
else
|
else
|
||||||
echo " ℹ️ step 2/3 -- setup-udev skipped (${PLATFORM})"
|
echo " ℹ️ step 2/3 -- setup-udev skipped ({{ PLATFORM }})"
|
||||||
fi
|
fi
|
||||||
echo " === Bootstrap: step 3/3 -- setup-tools ==="
|
echo " === Bootstrap: step 3/3 -- setup-tools ==="
|
||||||
just host::setup-tools
|
just host::setup-tools
|
||||||
|
|
@ -255,20 +242,23 @@ flash-ram project type="debug":
|
||||||
|
|
||||||
[doc('Прошить firmware_test Debug через USB SDP')]
|
[doc('Прошить firmware_test Debug через USB SDP')]
|
||||||
[group('flash-sdp')]
|
[group('flash-sdp')]
|
||||||
flash-test-debug:
|
flash-test-debug: (flash "firmware_test" "debug")
|
||||||
@just host::flash firmware_test debug
|
|
||||||
|
|
||||||
[doc('Прошить firmware_test Release через USB SDP')]
|
[doc('Прошить firmware_test Release через USB SDP')]
|
||||||
[group('flash-sdp')]
|
[group('flash-sdp')]
|
||||||
flash-test-release:
|
flash-test-release: (flash "firmware_test" "release")
|
||||||
@just host::flash firmware_test release
|
|
||||||
|
|
||||||
[confirm("Flash bootloader + app (Release)?")]
|
[confirm("Flash bootloader + app (Release)?")]
|
||||||
[doc('Прошить bootloader + tft_app Release через USB SDP (производство)')]
|
[doc('Прошить bootloader + tft_app Release через USB SDP (производство)')]
|
||||||
[group('flash-sdp')]
|
[group('flash-sdp')]
|
||||||
flash-production:
|
flash-production: (flash "bootloader" "release") (flash "app" "release")
|
||||||
@just host::flash bootloader release
|
|
||||||
@just host::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)
|
# ГРУППА: flash-swd — прошивка через SWD (MCU-Link, без смены BOOT_MODE)
|
||||||
|
|
@ -319,7 +309,7 @@ flash-swd-erase:
|
||||||
uv run --directory {{ HIL_DIR }} pyocd erase \
|
uv run --directory {{ HIL_DIR }} pyocd erase \
|
||||||
--target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \
|
--target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \
|
||||||
--frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \
|
--frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \
|
||||||
--chip
|
--chip -O resume_on_disconnect=False
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ГРУППА: hil — запуск HIL-тестов на реальном железе
|
# ГРУППА: hil — запуск HIL-тестов на реальном железе
|
||||||
|
|
@ -543,3 +533,36 @@ incoming: flash-test-release
|
||||||
[group('pipeline')]
|
[group('pipeline')]
|
||||||
production: flash-production
|
production: flash-production
|
||||||
@echo " ✅ Production firmware flashed"
|
@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"
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,28 @@ Python-окружение на базе [uv](https://docs.astral.sh/uv/) для
|
||||||
```bash
|
```bash
|
||||||
tools/host/
|
tools/host/
|
||||||
├── flash_usb.py — прошивка через USB ROM: sdphost → Flashloader → Flash
|
├── 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
|
├── 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/
|
├── dcd/
|
||||||
│ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP)
|
│ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP)
|
||||||
│ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P)
|
│ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P)
|
||||||
│ ├── w25q128_fdcb.bin — FCB для W25Q128 Quad SPI ← используется
|
│ ├── w25q128_fdcb.bin — FCB для W25Q128 Quad SPI ← используется
|
||||||
│ ├── w25q64_fdcb.bin — FCB для W25Q64 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-образы и процесс подписи
|
├── ../../docs/mimxrt1052/HAB_GUIDE.md — подробно про HAB-образы и процесс подписи
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
└── uv.lock
|
└── uv.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
> Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся
|
> Все бинарники в `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`.
|
Подробное описание обоих способов прошивки — в `docs/HOW_TO_FLASH.md`.
|
||||||
Сравнительная таблица, карта Flash, диагностика — там же.
|
Сравнительная таблица, карта Flash, диагностика — там же.
|
||||||
|
|
||||||
|
Прошивка сторонних/легаси бинарников с нестандартной памятью (явный FCB,
|
||||||
|
без auto-config) — через `service-tui` (`tools/production/`), не напрямую
|
||||||
|
через `flash_usb.py` из терминала. Детали конвейера — `tools/production/DEV_ARCH.md`, §8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
|
||||||
|
|
@ -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 app --build-type Release
|
||||||
python3 flash_usb.py --firmware bootloader --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 --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)
|
1. Устройство в SDP режиме (VID:PID 1FC9:0130)
|
||||||
→ sdphost загружает ivt_flashloader.bin в RAM
|
→ sdphost загружает ivt_flashloader.bin в RAM
|
||||||
→ sdphost прыгает на flashloader
|
→ sdphost прыгает на flashloader
|
||||||
2. Flashloader запущен (VID:PID 15A2:0073)
|
2. Flashloader запущен (VID:PID 15A2:0073)
|
||||||
→ blhost конфигурирует FlexSPI NOR (пишет FCB)
|
→ blhost конфигурирует FlexSPI NOR (пишет FCB)
|
||||||
→ blhost стирает нужный регион Flash
|
→ blhost стирает нужный регион Flash
|
||||||
→ blhost пишет HAB образ начиная с 0x60002000
|
→ blhost пишет HAB образ начиная с 0x60001000
|
||||||
→ blhost reset
|
→ blhost reset
|
||||||
|
|
||||||
|
Шаги (chip erase):
|
||||||
|
1. Загрузка Flashloader (аналогично прошивке)
|
||||||
|
2. blhost configure-memory (инициализация FlexSPI контроллера)
|
||||||
|
3. blhost flash-erase-all (полная очистка W25Q, ~30 с)
|
||||||
|
4. blhost reset
|
||||||
|
|
||||||
Конфигурация:
|
Конфигурация:
|
||||||
Переменные окружения (задаются в .env, экспортируются через just):
|
Переменные окружения (задаются в .env, экспортируются через just):
|
||||||
BOOTROM_VID — VID BootROM SDP (default: 1fc9)
|
BOOTROM_VID — VID BootROM SDP (default: 1fc9)
|
||||||
|
|
@ -39,23 +47,21 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||||
FLASHLOADER = SCRIPT_DIR / "dcd" / "ivt_flashloader.bin"
|
FLASHLOADER = SCRIPT_DIR / "dcd" / "ivt_flashloader.bin"
|
||||||
|
|
||||||
# BUILD_DIR: берём из окружения (just экспортирует из .env как абсолютный путь),
|
|
||||||
# fallback — рассчитываем от расположения скрипта
|
|
||||||
BUILD_DIR = Path(os.environ.get("BUILD_DIR", str(REPO_ROOT / "build")))
|
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:
|
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")
|
vid = os.environ.get(vid_key, vid_default).strip().upper().lstrip("0X")
|
||||||
pid = os.environ.get(pid_key, pid_default).strip().upper().lstrip("0X")
|
pid = os.environ.get(pid_key, pid_default).strip().upper().lstrip("0X")
|
||||||
return f"0x{vid},0x{pid}"
|
return f"0x{vid},0x{pid}"
|
||||||
|
|
||||||
|
|
||||||
SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130") # BootROM SDP
|
SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130")
|
||||||
BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073") # Flashloader
|
BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073")
|
||||||
|
|
||||||
# ─── Аппаратные константы (часть логики прошивки, не конфигурация) ────────────
|
# ─── Аппаратные константы ─────────────────────────────────────────────────────
|
||||||
# FlexSPI NOR config option word: 0xC0000007
|
# FlexSPI NOR config option word: 0xC0000007
|
||||||
# bits[31:28]=0xC — tag (QuadSPI NOR)
|
# bits[31:28]=0xC — tag (QuadSPI NOR)
|
||||||
# bits[3:0]=0x7 — option size
|
# bits[3:0]=0x7 — option size
|
||||||
|
|
@ -70,6 +76,10 @@ FLEXSPI_FCB_VALUE = "0xF000000F"
|
||||||
FLASH_BASE = 0x60000000
|
FLASH_BASE = 0x60000000
|
||||||
HAB_OFFSET = 0x1000 # IVT offset: write address = FLASH_BASE + HAB_OFFSET
|
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:
|
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||||
print(f" $ {' '.join(cmd)}")
|
print(f" $ {' '.join(cmd)}")
|
||||||
|
|
@ -87,7 +97,7 @@ def step(msg: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def wait_for_flashloader(timeout: int = 10) -> bool:
|
def wait_for_flashloader(timeout: int = 10) -> bool:
|
||||||
"""Ждём пока Flashloader поднимется"""
|
"""Ждём пока Flashloader поднимется (опрашиваем blhost раз в секунду)."""
|
||||||
print(f"\n Ожидание Flashloader (до {timeout}с)...", end="", flush=True)
|
print(f"\n Ожидание Flashloader (до {timeout}с)...", end="", flush=True)
|
||||||
for i in range(timeout):
|
for i in range(timeout):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
@ -104,7 +114,7 @@ def wait_for_flashloader(timeout: int = 10) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def load_flashloader() -> None:
|
def load_flashloader() -> None:
|
||||||
"""Загружает Flashloader через SDP если ещё не запущен"""
|
"""Загружает Flashloader через SDP если ещё не запущен."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["blhost", "-u", BLHOST_USB, "-j", "--", "get-property", "1", "0"],
|
["blhost", "-u", BLHOST_USB, "-j", "--", "get-property", "1", "0"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|
@ -114,9 +124,12 @@ def load_flashloader() -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not FLASHLOADER.exists():
|
if not FLASHLOADER.exists():
|
||||||
print(f"[ERROR] Не найден: {FLASHLOADER}")
|
print(f"[ERROR] Не найден: {FLASHLOADER}", file=sys.stderr)
|
||||||
print(" Скачай ivt_flashloader.bin из MCUXpresso Secure Provisioning Tool")
|
print(
|
||||||
print(" и положи в tools/host/dcd/ivt_flashloader.bin")
|
" Скачай ivt_flashloader.bin из MCUXpresso Secure Provisioning Tool\n"
|
||||||
|
" и положи в tools/host/dcd/ivt_flashloader.bin",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
step("Загрузка Flashloader через SDP (1FC9:0130)")
|
step("Загрузка Flashloader через SDP (1FC9:0130)")
|
||||||
|
|
@ -135,12 +148,15 @@ def load_flashloader() -> None:
|
||||||
run(["sdphost", "-u", SDP_USB, "-j", "--", "jump-address", "0x20001C00"])
|
run(["sdphost", "-u", SDP_USB, "-j", "--", "jump-address", "0x20001C00"])
|
||||||
|
|
||||||
if not wait_for_flashloader():
|
if not wait_for_flashloader():
|
||||||
print("[ERROR] Flashloader не ответил. Проверь BOOT_MOD пины и подключение.")
|
print(
|
||||||
|
"[ERROR] Flashloader не ответил. Проверь BOOT_MOD пины и подключение.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def configure_flexspi() -> None:
|
def configure_flexspi() -> None:
|
||||||
"""Инициализирует FlexSPI NOR контроллер через Flashloader"""
|
"""Инициализирует FlexSPI NOR контроллер через Flashloader."""
|
||||||
step("Конфигурация FlexSPI NOR (инициализация контроллера)")
|
step("Конфигурация FlexSPI NOR (инициализация контроллера)")
|
||||||
run(
|
run(
|
||||||
[
|
[
|
||||||
|
|
@ -169,9 +185,9 @@ def configure_flexspi() -> None:
|
||||||
|
|
||||||
|
|
||||||
def write_fcb() -> 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.
|
и пишет его по адресу 0x60000000. Без FCB BootROM не знает как читать Flash.
|
||||||
Option word 0xF000000F: tag=0xF → Write FCB command.
|
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():
|
if not hab_bin.exists():
|
||||||
print(f"[ERROR] Файл не найден: {hab_bin}")
|
print(f"[ERROR] Файл не найден: {hab_bin}", file=sys.stderr)
|
||||||
print(" Сначала собери образ: uv run nxpimage hab export -c ...")
|
print(
|
||||||
|
" Сначала собери образ: uv run nxpimage hab export -c ...", file=sys.stderr
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if ram_only:
|
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(
|
run(
|
||||||
[
|
[
|
||||||
|
|
@ -277,39 +331,137 @@ def flash(hab_bin: Path, ram_only: bool = False) -> None:
|
||||||
|
|
||||||
step("Reset")
|
step("Reset")
|
||||||
run(["blhost", "-u", BLHOST_USB, "--", "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:
|
def main() -> None:
|
||||||
print(f"DEBUG SDP_USB = {repr(SDP_USB)}")
|
parser = argparse.ArgumentParser(
|
||||||
print(f"DEBUG BLHOST_USB = {repr(BLHOST_USB)}")
|
description="Прошивка/очистка MIMXRT1052 через USB SDP",
|
||||||
print(f"DEBUG BUILD_DIR = {repr(BUILD_DIR)}")
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
parser = argparse.ArgumentParser(description="Прошивка MIMXRT1052 через USB")
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
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",
|
"--firmware",
|
||||||
required=True,
|
|
||||||
choices=["firmware_test", "bootloader", "app"],
|
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(
|
parser.add_argument(
|
||||||
"--build-type", required=True, choices=["Debug", "Release"], help="Тип сборки"
|
"--ram-only",
|
||||||
)
|
action="store_true",
|
||||||
parser.add_argument(
|
help="Загрузить в RAM без записи во Flash (только для --firmware / --bin-path)",
|
||||||
"--ram-only", action="store_true", help="Загрузить в RAM без записи во Flash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
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"\n{'═' * 60}")
|
||||||
print(f" MIMXRT1052 Flash Tool")
|
print(" MIMXRT1052 Flash Tool (USB SDP)")
|
||||||
print(f" Прошивка: {args.firmware} [{args.build_type}]")
|
if args.erase_chip:
|
||||||
print(f" Образ: {hab_bin}")
|
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" SDP USB: {SDP_USB}")
|
||||||
print(f" BL USB: {BLHOST_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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
276
tools/production/README.md
Normal file
276
tools/production/README.md
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
# service-tui — TUI сервисного инженера
|
||||||
|
|
||||||
|
TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе.
|
||||||
|
Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows.
|
||||||
|
|
||||||
|
> Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков —
|
||||||
|
> в [DEV_ARCH.md](DEV_ARCH.md). Этот документ — только про то, как приложением
|
||||||
|
> пользоваться.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Экраны приложения
|
||||||
|
|
||||||
|
### Ожидание подключения (WaitingScreen)
|
||||||
|
|
||||||
|
Стартовый экран. Автодетект USB — как только плата подключена, TUI сам
|
||||||
|
определяет режим (прошивка или диагностика) и переключается.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ service_tool v0.3.0 │
|
||||||
|
│ │
|
||||||
|
│ [LOGO_ART] │
|
||||||
|
│ │
|
||||||
|
│ Подключите плату индикатора к USB... ⠋ │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
При потере соединения на любом другом экране сессия разрывается полностью —
|
||||||
|
TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над
|
||||||
|
подсказкой на 4 секунды появляется строка `⚠ <причина>` (например,
|
||||||
|
«⚠ Соединение с платой потеряно»), затем скрывается сама — обычный
|
||||||
|
автодетект продолжается без вмешательства оператора.
|
||||||
|
|
||||||
|
### Прошивка платы (FlashScreen)
|
||||||
|
|
||||||
|
Триггер: обнаружена плата в режиме BootROM SDP.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ ⚡ Загрузка прошивки на плату индикатора │
|
||||||
|
│ (режим BootROM) │
|
||||||
|
│ │
|
||||||
|
│ Выбор загружаемой прошивки │
|
||||||
|
│ ◉ Диагностическая прошивка (firmware_test) │
|
||||||
|
│ ○ Серийная прошивка (bootloader + tft_app) │
|
||||||
|
│ ○ Другое │
|
||||||
|
│ Файл (custom_binaries/) │
|
||||||
|
│ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое»
|
||||||
|
│ Память платы │
|
||||||
|
│ [ W25Q128 / W25Q64 ▾ ] │
|
||||||
|
│ ○ Использует SDRAM (DCD) │
|
||||||
|
│ │
|
||||||
|
│ [ ▶ Загрузить ] [ ⚠ Очистить память ] [ ✕ Выйти из приложения ] │
|
||||||
|
│ │
|
||||||
|
│ ████████████░░░░░░ ← без числового % │
|
||||||
|
│ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ │ ▶ Сборка HAB-образа (nxpimage)... │ │
|
||||||
|
│ │ ▶ Прошивка: TFT_BOOTLOADER_NEW │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
|
||||||
|
во время активной операции (скрыт в простое), без числового `%` — только
|
||||||
|
полоса и построчный лог в реальном времени. Панель выбора прошивки
|
||||||
|
ограничена по высоте и скроллится сама, если разрастается (варианты
|
||||||
|
"Другое") — лог снизу гарантированно не сжимается меньше 6 строк.
|
||||||
|
|
||||||
|
**"Другое" — для бинарников, собранных не в этом репозитории.** В
|
||||||
|
`custom_binaries/` кладётся **сырой** образ (код + таблица векторов, без
|
||||||
|
FCB/IVT/DCD — то же самое, что `build/Debug/bootloader.bin` до `nxpimage`).
|
||||||
|
TUI сама собирает из него загружаемый образ на лету:
|
||||||
|
|
||||||
|
1. `nxpimage hab export` — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM")
|
||||||
|
2. в Flash пишется явный FCB под выбранную память платы (не тот же
|
||||||
|
auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для
|
||||||
|
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`)
|
||||||
|
3. образ прошивается с `0x60001000`, как обычно
|
||||||
|
|
||||||
|
**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не
|
||||||
|
нужно выставлять заново на каждой следующей плате: прошили одну, вынули
|
||||||
|
USB, вставили следующую такую же — TUI уже подставила прошлый выбор,
|
||||||
|
остаётся нажать "Загрузить". Сбрасывается только при перезапуске TUI.
|
||||||
|
|
||||||
|
### Переход в рабочий режим (PostFlashScreen)
|
||||||
|
|
||||||
|
Показывается **только** после успешной прошивки `firmware_test` (для
|
||||||
|
Production/Custom этот шаг не нужен).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ ✅ firmware_test успешно записан │
|
||||||
|
│ │
|
||||||
|
│ Переведите плату в нормальный режим: │
|
||||||
|
│ BOOT_MOD_1 → GND → Reset │
|
||||||
|
│ │
|
||||||
|
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
|
||||||
|
│ Автопереход через: 40с │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Диагностика (DiagScreen)
|
||||||
|
|
||||||
|
Триггер: плата видна как CDC-устройство и отвечает на связь.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ fw: 0.2.0 MCU ID: A1B2C3D4E5F60011 M5 Bench: ✓ подключён │
|
||||||
|
├───────────────────────────┬──────────────────────────────────────┤
|
||||||
|
│ Доступные тесты │ Тест M5 Bench Статус Время │
|
||||||
|
│ [Выбрать все][Снять все] │ microSD [-] ✗ FAIL 0.1с │
|
||||||
|
│ ☐ SDRAM 32 MB │ no card detected (без обрезки) │
|
||||||
|
│ ☐ QSPI Flash │ SDRAM 32 MB [-] ✓ PASS 1.8с │
|
||||||
|
│ ☐ microSD │ QSPI Flash [-] ✓ PASS 0.6с │
|
||||||
|
│ ☐ TFT Display │ TFT Display [-] … │
|
||||||
|
│ ☐ CAN loopback [HIL] │ Кнопки [-] │
|
||||||
|
│ ☐ Оптовходы [HIL] │ │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ████████░░░░ Тест: usd — mount: ok ← только во время прогона │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [▶ Запустить выбранные тесты] [▶▶ Запустить все тесты] [✕ Выйти из приложения] │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ⚠ Экран залит красным цветом? 28с │
|
||||||
|
│ [ ✓ Да ] [ ✗ Нет ] ← только во время confirm │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Что важно знать:
|
||||||
|
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
|
||||||
|
все"/"Снять все".
|
||||||
|
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
|
||||||
|
целиком, длинные сообщения переносятся на несколько строк без обрезания.
|
||||||
|
Колонка `M5 Bench` отмечает `[*]`/`[-]` — требует ли тест HIL-стенд.
|
||||||
|
- **HIL-тесты** (CAN, оптовходы) без подключённого M5StampPLC — серые,
|
||||||
|
недоступны для выбора.
|
||||||
|
- **Интерактивные проверки во время прогона теста** бывают трёх видов:
|
||||||
|
- HIL-тесты (CAN, оптовходы) при наличии M5 — отвечает стенд автоматически,
|
||||||
|
без участия оператора;
|
||||||
|
- тесты кнопок — экран показывает подсказку `⌨ <инструкция>`, ответ не
|
||||||
|
требуется, нужно физически нажать кнопку на плате;
|
||||||
|
- остальное (дисплей, аудио) — форма "Да/Нет" с таймером `⚠ <вопрос>` на
|
||||||
|
экране.
|
||||||
|
- До завершения загрузки списка тестов правая колонка показывает подсказку
|
||||||
|
«Выберите тесты и нажмите «Запустить выбранные»» вместо таблицы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рабочие процессы сервисника
|
||||||
|
|
||||||
|
### Диагностика (firmware_test уже прошит)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. BOOT_MOD_1 → GND, сбросить плату
|
||||||
|
2. Подключить USB → DiagScreen
|
||||||
|
3. Выбрать тесты (по умолчанию ничего не выбрано) или "Выбрать все"
|
||||||
|
4. Запустить → ответить на интерактивные запросы
|
||||||
|
5. Получить итог; FAIL-тесты — наверху таблицы, detail виден полностью
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перепрошивка firmware_test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
|
||||||
|
2. Выбрать «Диагностическая прошивка (firmware_test)» → Загрузить
|
||||||
|
3. PostFlashScreen: BOOT_MOD_1 → GND, сбросить плату
|
||||||
|
4. Нажать "Готово" (или дождаться авто-перехода через 40с)
|
||||||
|
5. TUI автоматически попадает в DiagScreen при следующем подключении
|
||||||
|
```
|
||||||
|
|
||||||
|
### Прошивка стороннего бинарника (custom_binaries/)
|
||||||
|
|
||||||
|
Для плат старых ревизий и любых образов, собранных не в этом репозитории.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. Положить сырой бинарник (без FCB/IVT/DCD) в custom_binaries/
|
||||||
|
(или в директорию из SERVICE_CUSTOM_BINARIES_DIR)
|
||||||
|
2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
|
||||||
|
3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD при необходимости
|
||||||
|
4. Загрузить — TUI сама соберёт HAB-образ и запишет правильный FCB
|
||||||
|
5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже
|
||||||
|
подставлен, останется нажать «Загрузить»
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chip Erase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3)
|
||||||
|
2. FlashScreen → Очистить память (~30 с для W25Q128, дольше для W25Q512)
|
||||||
|
3. После erase BootROM не загрузит прошивку — требуется перепрошить
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация (`.env`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# USB VID:PID — BootROM SDP (константы NXP, не менять)
|
||||||
|
BOOTROM_VID=1fc9
|
||||||
|
BOOTROM_PID=0130
|
||||||
|
|
||||||
|
# USB VID:PID — Flashloader (константы NXP, не менять)
|
||||||
|
FLASHLOADER_VID=15a2
|
||||||
|
FLASHLOADER_PID=0073
|
||||||
|
|
||||||
|
# USB VID:PID — firmware_test CDC (наше устройство)
|
||||||
|
SERVICE_CDC_VID=1996
|
||||||
|
SERVICE_CDC_PID=00ad
|
||||||
|
|
||||||
|
# USB VID:PID — M5StampPLC (детект HIL-стенда)
|
||||||
|
SERVICE_M5_VID=303a
|
||||||
|
SERVICE_M5_PID=4001
|
||||||
|
|
||||||
|
# Директория с сырыми кастомными бинарниками для FlashScreen → "Другое".
|
||||||
|
# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом
|
||||||
|
# с main.py в dev-режиме); создаётся автоматически при старте.
|
||||||
|
# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries
|
||||||
|
|
||||||
|
# Тип сборки firmware_test для прошивки (Debug | Release).
|
||||||
|
# Release временно нестабилен — по умолчанию Debug.
|
||||||
|
FIRMWARE_BUILD_TYPE=Debug
|
||||||
|
|
||||||
|
# Опционально: путь к директории лога TUI
|
||||||
|
# SERVICE_LOG_DIR=/tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Из монорепозитория (разработчик)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just host::service-setup # установить зависимости tools/production/
|
||||||
|
just host::service-tui # запустить TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone-бинарь (сервисник)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just host::service-build
|
||||||
|
# → tools/production/dist/service_tui
|
||||||
|
```
|
||||||
|
|
||||||
|
> Standalone-бинарь не включает `tools/host/` — для прошивки рядом нужен
|
||||||
|
> инициализированный `tools/host/` (`just host::setup-tools`), либо
|
||||||
|
> абсолютный путь в `_FLASH_USB_SCRIPT` (`flasher.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
| Пакет | Версия | Назначение |
|
||||||
|
| --------------- | ------ | ----------------------------------------------------- |
|
||||||
|
| `textual` | ≥ 0.80 | TUI фреймворк |
|
||||||
|
| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial |
|
||||||
|
| `pyusb` | ≥ 1.0 | детект BootROM SDP (не виден через pyserial на macOS) |
|
||||||
|
| `python-dotenv` | ≥ 1.0 | загрузка `.env` |
|
||||||
|
| `pyinstaller` | ≥ 6.0 | сборка standalone-бинаря |
|
||||||
|
|
||||||
|
**Runtime-зависимость (не в `pyproject.toml`):** `flasher.py` вызывает
|
||||||
|
`tools/host/flash_usb.py` через `uv run` — `tools/host/` должен быть
|
||||||
|
инициализирован (`just host::setup-tools`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/production/service_tui.log ← по умолчанию
|
||||||
|
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Уровень: `DEBUG` для модулей приложения, `WARNING` для самого textual. TUI не
|
||||||
|
пишет в stdout — Textual захватывает терминал.
|
||||||
125
tools/production/app/app.py
Normal file
125
tools/production/app/app.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
app.py — корневое Textual приложение.
|
||||||
|
|
||||||
|
Управляет сменой экранов и жизненным циклом клиентов
|
||||||
|
(FirmwareClient, M5Client).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import App
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
from .firmware_client import FirmwareClient
|
||||||
|
from .m5_client import M5Client
|
||||||
|
from .models import AppMode, FlashPreset, FlashTarget
|
||||||
|
from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16)
|
||||||
|
_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceApp(App):
|
||||||
|
"""Корневое приложение service-tui."""
|
||||||
|
|
||||||
|
TITLE = "TFT Board Service Tool"
|
||||||
|
CSS_PATH = "app.tcss" # относительно app/app.py → app/app.tcss
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("ctrl+c", "quit", "Выход", show=True),
|
||||||
|
Binding("ctrl+q", "quit", "Выход"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._fw: Optional[FirmwareClient] = None
|
||||||
|
self._m5: Optional[M5Client] = None
|
||||||
|
# «Липкий» выбор оператора на FlashScreen — переносится на следующую
|
||||||
|
# плату в рамках одного запуска TUI (см. FlashPreset docstring).
|
||||||
|
# Сбрасывается при перезапуске TUI, не персистится на диск.
|
||||||
|
self._last_flash_preset = FlashPreset()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen(WaitingScreen())
|
||||||
|
|
||||||
|
# ── Переходы между экранами ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(WaitingScreen.DeviceDetected)
|
||||||
|
@on(WaitingScreen.DeviceDetected)
|
||||||
|
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
|
||||||
|
if event.mode == AppMode.FLASHING:
|
||||||
|
self.switch_screen(FlashScreen(preset=self._last_flash_preset))
|
||||||
|
elif event.mode == AppMode.DIAGNOSING:
|
||||||
|
self._connect_and_diagnose()
|
||||||
|
|
||||||
|
@on(FlashScreen.FlashDone)
|
||||||
|
def _on_flash_done(self, event: FlashScreen.FlashDone) -> None:
|
||||||
|
"""
|
||||||
|
После прошивки:
|
||||||
|
- firmware_test + успех → PostFlashScreen (промпт смены BootMode)
|
||||||
|
- всё остальное (production/custom, либо ошибка) → WaitingScreen
|
||||||
|
- target=None — маркер потери соединения (см. ConnectionWatcherMixin)
|
||||||
|
→ WaitingScreen с явной причиной возврата
|
||||||
|
"""
|
||||||
|
if event.preset is not None:
|
||||||
|
self._last_flash_preset = event.preset
|
||||||
|
|
||||||
|
if event.target is None and not event.success:
|
||||||
|
self.switch_screen(
|
||||||
|
WaitingScreen(disconnect_reason="Соединение с платой потеряно")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.success and event.target == FlashTarget.FIRMWARE_TEST:
|
||||||
|
self.switch_screen(PostFlashScreen())
|
||||||
|
else:
|
||||||
|
self.switch_screen(WaitingScreen())
|
||||||
|
|
||||||
|
@on(PostFlashScreen.Done)
|
||||||
|
def _on_post_flash_done(self) -> None:
|
||||||
|
"""Оператор подтвердил смену BootMode или истёк таймаут."""
|
||||||
|
self.switch_screen(WaitingScreen())
|
||||||
|
|
||||||
|
@on(DiagScreen.DiagDone)
|
||||||
|
def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
|
||||||
|
"""После диагностики — отключиться, вернуться в Waiting."""
|
||||||
|
self._disconnect()
|
||||||
|
self.switch_screen(WaitingScreen(disconnect_reason=event.reason))
|
||||||
|
|
||||||
|
# ── Подключение к firmware_test ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(thread=False)
|
||||||
|
async def _connect_and_diagnose(self) -> None:
|
||||||
|
try:
|
||||||
|
self._fw = await FirmwareClient.auto_connect(vid=_CDC_VID, pid=_CDC_PID)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("CDC connect failed: %s", exc)
|
||||||
|
self.switch_screen(WaitingScreen())
|
||||||
|
return
|
||||||
|
|
||||||
|
self._m5 = await M5Client.auto_connect()
|
||||||
|
|
||||||
|
fw_version = ""
|
||||||
|
try:
|
||||||
|
fw_version = await self._fw.get_version()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("get_version failed: %s", exc)
|
||||||
|
|
||||||
|
self.switch_screen(
|
||||||
|
DiagScreen(firmware=self._fw, m5=self._m5, fw_version=fw_version)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _disconnect(self) -> None:
|
||||||
|
if self._fw is not None:
|
||||||
|
self.call_later(self._fw.disconnect)
|
||||||
|
self._fw = None
|
||||||
|
if self._m5 is not None:
|
||||||
|
self.call_later(self._m5.disconnect)
|
||||||
|
self._m5 = None
|
||||||
391
tools/production/app/app.tcss
Normal file
391
tools/production/app/app.tcss
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
service-tui — единый файл стилей
|
||||||
|
Путь резолвится относительно app/app.py → app/app.tcss
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Общая рамка приложения (AppFrame) ──────────────────── */
|
||||||
|
/* Все три экрана центрируют один AppFrame фиксированного */
|
||||||
|
/* размера — единообразный визуальный каркас независимо от */
|
||||||
|
/* размера окна терминала (как в ratatui-приложениях). */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppFrame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 112;
|
||||||
|
max-height: 40;
|
||||||
|
border: heavy $primary;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── WaitingScreen ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
#waiting-frame {
|
||||||
|
align: center top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-logo-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-logo-art {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-version-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-version {
|
||||||
|
color: $warning;
|
||||||
|
text-style: bold;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
#waiting-reason {
|
||||||
|
margin-top: 1;
|
||||||
|
padding: 1 2;
|
||||||
|
border: round $warning;
|
||||||
|
color: $warning;
|
||||||
|
text-style: bold;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-reason.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-hint {
|
||||||
|
margin-top: 2;
|
||||||
|
color: $text-muted;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waiting-spinner {
|
||||||
|
margin-top: 1;
|
||||||
|
color: $accent;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FlashScreen ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#flash-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $warning;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-target-group {
|
||||||
|
border: round $panel;
|
||||||
|
padding: 1 2;
|
||||||
|
margin-bottom: 1;
|
||||||
|
height: auto;
|
||||||
|
max-height: 18;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-target-group .section-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-custom-group {
|
||||||
|
margin-top: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-custom-group.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-custom-select,
|
||||||
|
#flash-fcb-select {
|
||||||
|
width: 1fr;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-dcd-row {
|
||||||
|
height: auto;
|
||||||
|
align: left middle;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-dcd-switch {
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-btn-row {
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-btn-flash,
|
||||||
|
#flash-btn-erase {
|
||||||
|
margin-right: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-btn-flash:disabled,
|
||||||
|
#flash-btn-erase:disabled,
|
||||||
|
#flash-btn-quit:disabled {
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-progress-bar {
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-progress-bar.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-log {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 6;
|
||||||
|
margin-top: 1;
|
||||||
|
border: round $panel;
|
||||||
|
background: $surface-darken-1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PostFlashScreen ────────────────────────────────────── */
|
||||||
|
|
||||||
|
#post-flash-frame {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $success;
|
||||||
|
content-align: center middle;
|
||||||
|
margin-bottom: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-instruction {
|
||||||
|
border: round $warning;
|
||||||
|
padding: 1 3;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
content-align: center middle;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-countdown {
|
||||||
|
margin-top: 2;
|
||||||
|
color: $text-muted;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-btn-row {
|
||||||
|
margin-top: 2;
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-btn-ok {
|
||||||
|
margin-right: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-flash-title-row,
|
||||||
|
#post-flash-instruction-row {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
/* ── DiagScreen ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#diag-frame {
|
||||||
|
layout: vertical;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-header {
|
||||||
|
height: 3;
|
||||||
|
background: $panel;
|
||||||
|
padding: 0 2;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-header-fw {
|
||||||
|
width: auto;
|
||||||
|
min-width: 14;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-right: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-header-uid {
|
||||||
|
width: auto;
|
||||||
|
min-width: 28;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-right: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-header-m5 {
|
||||||
|
width: auto;
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-header-m5.m5-absent {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-main {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-progress-row {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-progress-row.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-progress-bar {
|
||||||
|
height: 1;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-progress-label {
|
||||||
|
height: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-btn-row {
|
||||||
|
height: 3;
|
||||||
|
align: left middle;
|
||||||
|
padding: 0 2;
|
||||||
|
background: $panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-btn-run-selected,
|
||||||
|
#diag-btn-run-all {
|
||||||
|
margin-right: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#diag-btn-run-selected:disabled,
|
||||||
|
#diag-btn-run-all:disabled {
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TestListPanel ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
TestListPanel {
|
||||||
|
width: 38;
|
||||||
|
border-right: solid $panel;
|
||||||
|
padding: 1 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestListPanel .section-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#test-list-select-row {
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#test-list-select-row Button {
|
||||||
|
margin-right: 1;
|
||||||
|
min-width: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-row {
|
||||||
|
height: auto;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-row-hil-badge {
|
||||||
|
width: 6;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-row-hil-badge.hil-disabled {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ResultsPanel ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
ResultsPanel {
|
||||||
|
width: 70;
|
||||||
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResultsPanel .section-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-empty {
|
||||||
|
height: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-empty.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-empty-text {
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-table {
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results-table.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ConfirmPanel ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
ConfirmPanel {
|
||||||
|
height: auto;
|
||||||
|
background: $warning 15%;
|
||||||
|
border-top: solid $warning;
|
||||||
|
padding: 1 2;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmPanel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-prompt {
|
||||||
|
width: 1fr;
|
||||||
|
color: $warning;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-countdown {
|
||||||
|
color: $text-muted;
|
||||||
|
margin-right: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-btn-ok {
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-btn-ok.hidden,
|
||||||
|
#confirm-btn-fail.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
33
tools/production/app/boot_art.py
Normal file
33
tools/production/app/boot_art.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""
|
||||||
|
boot_art.py — ASCII-логотип компании для WaitingScreen.
|
||||||
|
|
||||||
|
Сгенерирован программно (coverage-based растеризация logo.png в
|
||||||
|
символы плотности + разделение по цвету blue/dark), затем зафиксирован
|
||||||
|
как статичная строка — рантайм-зависимости на Pillow/etc. не требуется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LOGO_ART = """
|
||||||
|
[grey37]........[/grey37][#3ca0dc].**:+*::*+.[/#3ca0dc][grey37]..........[/grey37]
|
||||||
|
[grey37]........[/grey37][#3ca0dc].##:*#++#*.[/#3ca0dc][grey37]..........[/grey37]
|
||||||
|
[grey37].........[/grey37][#3ca0dc]*#[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#..#+[/#3ca0dc][grey37]...........[/grey37]
|
||||||
|
[grey37].......[/grey37][#3ca0dc].+#:[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#..#+[/#3ca0dc][grey37]...........[/grey37]
|
||||||
|
[grey37].....[/grey37][#3ca0dc].+#+..:#*..#+[/#3ca0dc][grey37]...........[/grey37]
|
||||||
|
[grey37]...[/grey37][#3ca0dc].+#+..:**:..*#.[/#3ca0dc][white].:.[/white][grey37]........[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc].+#+..:#*:..*#+.[/#3ca0dc][white]+@@+[/white][grey37]........[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]##..:#*.[/#3ca0dc][grey37].[/grey37][#3ca0dc].*#+.[/#3ca0dc][white]%@@@@%[/white][grey37].[/grey37][white]::.[/white][grey37]....[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]:#:[/#3ca0dc][grey37].[/grey37][#3ca0dc].*#+.[/#3ca0dc][white]+@@@@@@%[/white][grey37].[/grey37][white]+@...[/white][grey37]..[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#..#+.[/#3ca0dc][grey37]........[/grey37][white]%%[/white][grey37].[/grey37][white]+@..%.[/white][grey37].[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]#*[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#:[/#3ca0dc][grey37].........[/grey37][white]%%[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]**[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#:[/#3ca0dc][grey37]........[/grey37][white].%%[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
|
||||||
|
[grey37].[/grey37][#3ca0dc]..[/#3ca0dc][grey37].[/grey37][#3ca0dc]+#.:#*+++++.[/#3ca0dc][white].:@%.[/white][grey37].[/grey37][white]+@..@:[/white][grey37].[/grey37]
|
||||||
|
[grey37]....[/grey37][#3ca0dc].*.:#####+.[/#3ca0dc][white]+@%.[/white][grey37].[/grey37][white]:%%:[/white][grey37].[/grey37][white]:@:[/white][grey37].[/grey37]
|
||||||
|
[grey37]......[/grey37][#3ca0dc].:###+.[/#3ca0dc][white]+@%.[/white][grey37].[/grey37][white].%%:..+@+.[/white][grey37].[/grey37]
|
||||||
|
[grey37].......[/grey37][#3ca0dc].:+.[/#3ca0dc][white]:@%.[/white][grey37].[/grey37][white].%@:..+@+.[/white][grey37]...[/grey37]
|
||||||
|
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white].%@:..+@+.[/white][grey37].....[/grey37]
|
||||||
|
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white]%@..+@+.[/white][grey37].......[/grey37]
|
||||||
|
[grey37]...........[/grey37][white]@%[/white][grey37].[/grey37][white]%@[/white][grey37].[/grey37][white]:@:[/white][grey37].........[/grey37]
|
||||||
|
[grey37]..........[/grey37][white].@%.%@.+@+[/white][grey37].........[/grey37]
|
||||||
|
[grey37]..........[/grey37][white]:@@:@@+%@%.[/white][grey37]........[/grey37]
|
||||||
|
""".strip("\n")
|
||||||
253
tools/production/app/firmware_client.py
Normal file
253
tools/production/app/firmware_client.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""
|
||||||
|
firmware_client.py — async CDC-клиент firmware_test.
|
||||||
|
|
||||||
|
Транспорт: USB CDC ACM (pyserial в asyncio thread executor).
|
||||||
|
Протокол: JSON-lines v2 (один JSON-объект на строку, завершается '\\n').
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
FirmwareClient.connect() — открыть порт, проверить 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)
|
||||||
430
tools/production/app/flasher.py
Normal file
430
tools/production/app/flasher.py
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
"""
|
||||||
|
flasher.py — обёртка над tools/host/flash_usb.py для TUI.
|
||||||
|
|
||||||
|
Запускает flash_usb.py как subprocess, парсит stdout для прогресса,
|
||||||
|
пробрасывает события через asyncio.Queue в TUI.
|
||||||
|
|
||||||
|
Не дублирует spsdk-окружение — переиспользует tools/host/ uv-проект.
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
Flasher.flash(target, bin_path, progress_cb) — async, прогресс через callback
|
||||||
|
Flasher.erase_chip(progress_cb) — async chip erase
|
||||||
|
Flasher.detect_sdp() — проверить наличие BootROM SDP
|
||||||
|
Flasher.detect_cdc() — проверить наличие CDC firmware_test
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
from .models import FcbVariant, FlashProgress, FlashTarget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# VID/PID констант — читаются из env, fallback на известные значения
|
||||||
|
_BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16)
|
||||||
|
_BOOTROM_PID = int(os.environ.get("BOOTROM_PID", "0x0130"), 16)
|
||||||
|
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16)
|
||||||
|
_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16)
|
||||||
|
|
||||||
|
# Тип сборки firmware_test для прошивки (Debug | Release).
|
||||||
|
# Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug.
|
||||||
|
_FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug")
|
||||||
|
|
||||||
|
# Путь до flash_usb.py относительно корня репозитория
|
||||||
|
_FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py"
|
||||||
|
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent
|
||||||
|
_HAB_DIR = _HOST_TOOLS_DIR / "hab"
|
||||||
|
_DCD_DIR = _HOST_TOOLS_DIR / "dcd"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_custom_binaries_dir() -> Path:
|
||||||
|
"""
|
||||||
|
Директория с «сырыми» кастомными бинарниками для FlashScreen.
|
||||||
|
|
||||||
|
Не пакуется в PyInstaller-бандл — внешняя директория, путь к которой
|
||||||
|
можно переопределить через SERVICE_CUSTOM_BINARIES_DIR. sys.executable
|
||||||
|
указывает на реальный exe и для --onefile, и для --onedir (в отличие
|
||||||
|
от sys._MEIPASS — временной распаковки onefile).
|
||||||
|
"""
|
||||||
|
override = os.environ.get("SERVICE_CUSTOM_BINARIES_DIR")
|
||||||
|
if override:
|
||||||
|
base = Path(override)
|
||||||
|
elif getattr(sys, "frozen", False):
|
||||||
|
base = Path(sys.executable).resolve().parent / "custom_binaries"
|
||||||
|
else:
|
||||||
|
base = Path(__file__).parents[1] / "custom_binaries"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
CUSTOM_BINARIES_DIR = _resolve_custom_binaries_dir()
|
||||||
|
|
||||||
|
# Паттерны stdout flash_usb.py для извлечения прогресса
|
||||||
|
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
|
||||||
|
_RE_PHASE = re.compile(r"(sdphost|blhost|Writing|Erasing|Verifying)", re.IGNORECASE)
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[FlashProgress], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_usb(vid: int, pid: int) -> bool:
|
||||||
|
"""
|
||||||
|
Проверить наличие USB-устройства по VID/PID (синхронно).
|
||||||
|
|
||||||
|
Два метода детекта:
|
||||||
|
1. pyusb (usb.core) — видит все USB-устройства включая SDP bulk/HID.
|
||||||
|
На macOS SDP-устройство (1FC9:0130) не создаёт serial-порт
|
||||||
|
и невидимо через serial.tools.list_ports.
|
||||||
|
2. serial.tools.list_ports — fallback для CDC ACM устройств.
|
||||||
|
"""
|
||||||
|
# Метод 1: pyusb — работает для SDP и CDC
|
||||||
|
try:
|
||||||
|
import usb.core
|
||||||
|
|
||||||
|
dev = usb.core.find(idVendor=vid, idProduct=pid)
|
||||||
|
if dev is not None:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Метод 2: serial list_ports — fallback для CDC ACM
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
for info in serial.tools.list_ports.comports():
|
||||||
|
if info.vid == vid and info.pid == pid:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Flasher:
|
||||||
|
"""
|
||||||
|
Async-обёртка над flash_usb.py.
|
||||||
|
|
||||||
|
Пример::
|
||||||
|
|
||||||
|
flasher = Flasher()
|
||||||
|
await flasher.flash(
|
||||||
|
target=FlashTarget.FIRMWARE_TEST,
|
||||||
|
progress_cb=lambda p: print(p.message),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._proc: Optional[asyncio.subprocess.Process] = None
|
||||||
|
|
||||||
|
# ── Detection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_sdp() -> bool:
|
||||||
|
"""True если виден BootROM SDP (1FC9:0130)."""
|
||||||
|
return _detect_usb(_BOOTROM_VID, _BOOTROM_PID)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_cdc() -> bool:
|
||||||
|
"""True если виден CDC firmware_test (1996:00AD)."""
|
||||||
|
return _detect_usb(_CDC_VID, _CDC_PID)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_custom_binaries() -> list[Path]:
|
||||||
|
"""Отсканировать custom_binaries/ на *.bin, отсортировано по имени."""
|
||||||
|
return sorted(CUSTOM_BINARIES_DIR.glob("*.bin"))
|
||||||
|
|
||||||
|
# ── Erase ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def erase_chip(
|
||||||
|
self,
|
||||||
|
progress_cb: Optional[ProgressCallback] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Chip erase Flash через USB SDP (flash_usb.py --erase-chip).
|
||||||
|
Занимает ~30 с для W25Q128. FCB будет стёрт.
|
||||||
|
|
||||||
|
:return: True при успехе.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
str(_HOST_TOOLS_DIR),
|
||||||
|
"python",
|
||||||
|
str(_FLASH_USB_SCRIPT),
|
||||||
|
"--erase-chip",
|
||||||
|
]
|
||||||
|
return await self._run_cmd(cmd, "erase", progress_cb)
|
||||||
|
|
||||||
|
# ── Flash ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def flash(
|
||||||
|
self,
|
||||||
|
target: FlashTarget,
|
||||||
|
progress_cb: Optional[ProgressCallback] = None,
|
||||||
|
bin_path: Optional[Path] = None,
|
||||||
|
use_dcd: bool = False,
|
||||||
|
fcb_variant: FcbVariant = FcbVariant.W25Q128,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Запустить прошивку через flash_usb.py.
|
||||||
|
|
||||||
|
:param target: Что прошиваем (firmware_test, production или custom).
|
||||||
|
:param progress_cb: Async callback с FlashProgress (может быть None).
|
||||||
|
:param bin_path: Путь к бинарю (обязателен для CUSTOM).
|
||||||
|
:param use_dcd: Только для CUSTOM — включить DCDFilePath (SDRAM-init)
|
||||||
|
при сборке HAB-образа через nxpimage.
|
||||||
|
:param fcb_variant: Только для CUSTOM — какой явный FCB-блоб (dcd/*_fdcb.bin)
|
||||||
|
записать в Flash[0x60000000] вместо auto-config.
|
||||||
|
:return: True при успехе.
|
||||||
|
"""
|
||||||
|
if target == FlashTarget.FIRMWARE_TEST:
|
||||||
|
if bin_path is not None:
|
||||||
|
return await self._run_flash_bin(bin_path, progress_cb)
|
||||||
|
return await self._run_flash("firmware_test", progress_cb)
|
||||||
|
elif target == FlashTarget.PRODUCTION:
|
||||||
|
ok = await self._run_flash("bootloader", progress_cb)
|
||||||
|
if ok:
|
||||||
|
ok = await self._run_flash("app", progress_cb)
|
||||||
|
return ok
|
||||||
|
elif target == FlashTarget.CUSTOM:
|
||||||
|
if bin_path is None:
|
||||||
|
raise ValueError("FlashTarget.CUSTOM требует bin_path")
|
||||||
|
return await self._run_flash_custom(
|
||||||
|
bin_path, use_dcd, fcb_variant, progress_cb
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _run_flash_custom(
|
||||||
|
self,
|
||||||
|
raw_bin_path: Path,
|
||||||
|
use_dcd: bool,
|
||||||
|
fcb_variant: FcbVariant,
|
||||||
|
progress_cb: Optional[ProgressCallback],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Прошить «сырой» (не-HAB) кастомный бинарник из custom_binaries/.
|
||||||
|
|
||||||
|
Два шага:
|
||||||
|
1. Собрать HAB-образ (IVT + опционально DCD, БЕЗ FCB) через
|
||||||
|
nxpimage — так же, как just build::hab-* собирает штатные
|
||||||
|
прошивки, только конфиг генерируется на лету под выбранный файл.
|
||||||
|
2. Прошить получившийся HAB-образ через flash_usb.py --bin-path,
|
||||||
|
подставив явный FCB-блоб (--fcb-path) под выбранный тип памяти —
|
||||||
|
см. FcbVariant.
|
||||||
|
"""
|
||||||
|
if progress_cb is not None:
|
||||||
|
await progress_cb(
|
||||||
|
FlashProgress(
|
||||||
|
phase="nxpimage", percent=0, message="Сборка HAB-образа..."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
hab_bin = await self._build_custom_hab(raw_bin_path, use_dcd, progress_cb)
|
||||||
|
if hab_bin is None:
|
||||||
|
if progress_cb is not None:
|
||||||
|
await progress_cb(
|
||||||
|
FlashProgress(
|
||||||
|
phase="error",
|
||||||
|
percent=0,
|
||||||
|
message="Ошибка сборки HAB-образа (nxpimage)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
fcb_path = _DCD_DIR / fcb_variant.fcb_filename
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
str(_HOST_TOOLS_DIR),
|
||||||
|
"python",
|
||||||
|
str(_FLASH_USB_SCRIPT),
|
||||||
|
"--bin-path",
|
||||||
|
str(hab_bin),
|
||||||
|
"--fcb-path",
|
||||||
|
str(fcb_path),
|
||||||
|
]
|
||||||
|
return await self._run_cmd(cmd, raw_bin_path.stem, progress_cb)
|
||||||
|
finally:
|
||||||
|
hab_bin.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
async def _build_custom_hab(
|
||||||
|
self,
|
||||||
|
raw_bin: Path,
|
||||||
|
use_dcd: bool,
|
||||||
|
progress_cb: Optional[ProgressCallback] = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Собрать HAB-образ из сырого бинарника через nxpimage.
|
||||||
|
|
||||||
|
Временный YAML пишется прямо в tools/host/hab/ (как и штатные
|
||||||
|
hab_*.yaml) и nxpimage запускается с cwd=tools/host/hab/ — это
|
||||||
|
обязательно: относительный DCDFilePath ("../dcd/dcd.bin") в
|
||||||
|
существующих конфигах резолвится именно так (см. build.just,
|
||||||
|
группа hab_image_gen — `cd tools/host/hab && uv run nxpimage ...`).
|
||||||
|
Отходить от этой схемы рискованно — nxpimage не документирует
|
||||||
|
явно, от чего резолвит относительные пути.
|
||||||
|
|
||||||
|
:return: путь к собранному *.hab.bin, либо None при ошибке nxpimage.
|
||||||
|
"""
|
||||||
|
yaml_f = tempfile.NamedTemporaryFile(
|
||||||
|
dir=_HAB_DIR, suffix=".yaml", prefix="_tui_custom_", delete=False
|
||||||
|
)
|
||||||
|
yaml_path = Path(yaml_f.name)
|
||||||
|
out_path = yaml_path.with_suffix(".hab.bin")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"options:",
|
||||||
|
" flags: 0x00",
|
||||||
|
" startAddress: 0x60000000",
|
||||||
|
" ivtOffset: 0x1000",
|
||||||
|
" initialLoadSize: 0x2000",
|
||||||
|
" family: mimxrt1050",
|
||||||
|
]
|
||||||
|
if use_dcd:
|
||||||
|
lines.append(" DCDFilePath: ../dcd/dcd.bin")
|
||||||
|
lines.append(f'inputImageFile: "{raw_bin.resolve()}"')
|
||||||
|
lines.append("sections: []")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yaml_f.write("\n".join(lines).encode("utf-8"))
|
||||||
|
yaml_f.close()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"nxpimage",
|
||||||
|
"hab",
|
||||||
|
"export",
|
||||||
|
"--force",
|
||||||
|
"-c",
|
||||||
|
str(yaml_path),
|
||||||
|
"-o",
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
logger.info("Building custom HAB image: %s", " ".join(cmd))
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
cwd=str(_HAB_DIR),
|
||||||
|
)
|
||||||
|
assert proc.stdout is not None
|
||||||
|
async for raw_line in proc.stdout:
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
logger.debug("nxpimage: %s", line)
|
||||||
|
if progress_cb is not None and line:
|
||||||
|
await progress_cb(
|
||||||
|
FlashProgress(phase="nxpimage", percent=0, message=line)
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
if proc.returncode != 0 or not out_path.exists():
|
||||||
|
logger.error("nxpimage hab export failed (rc=%s)", proc.returncode)
|
||||||
|
return None
|
||||||
|
return out_path
|
||||||
|
finally:
|
||||||
|
yaml_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
async def _run_flash(
|
||||||
|
self,
|
||||||
|
firmware: str,
|
||||||
|
progress_cb: Optional[ProgressCallback],
|
||||||
|
) -> bool:
|
||||||
|
"""Запустить flash_usb.py для одного бинаря (стандартный firmware)."""
|
||||||
|
cmd = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
str(_HOST_TOOLS_DIR),
|
||||||
|
"python",
|
||||||
|
str(_FLASH_USB_SCRIPT),
|
||||||
|
"--firmware",
|
||||||
|
firmware,
|
||||||
|
"--build-type",
|
||||||
|
_FIRMWARE_BUILD_TYPE,
|
||||||
|
]
|
||||||
|
return await self._run_cmd(cmd, firmware, progress_cb)
|
||||||
|
|
||||||
|
async def _run_flash_bin(
|
||||||
|
self,
|
||||||
|
bin_path: Path,
|
||||||
|
progress_cb: Optional[ProgressCallback],
|
||||||
|
) -> bool:
|
||||||
|
"""Запустить flash_usb.py для произвольного бинаря."""
|
||||||
|
cmd = [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
str(_HOST_TOOLS_DIR),
|
||||||
|
"python",
|
||||||
|
str(_FLASH_USB_SCRIPT),
|
||||||
|
"--bin-path",
|
||||||
|
str(bin_path.resolve()),
|
||||||
|
]
|
||||||
|
return await self._run_cmd(cmd, bin_path.stem, progress_cb)
|
||||||
|
|
||||||
|
async def _run_cmd(
|
||||||
|
self,
|
||||||
|
cmd: list[str],
|
||||||
|
label: str,
|
||||||
|
progress_cb: Optional[ProgressCallback],
|
||||||
|
) -> bool:
|
||||||
|
"""Общий subprocess runner для всех операций flash_usb.py."""
|
||||||
|
logger.info("Running: %s", " ".join(cmd))
|
||||||
|
try:
|
||||||
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
cwd=str(_HOST_TOOLS_DIR),
|
||||||
|
)
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
async for raw_line in self._proc.stdout:
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
logger.debug("flash_usb [%s]: %s", label, line)
|
||||||
|
if progress_cb is not None:
|
||||||
|
progress = _parse_progress(line)
|
||||||
|
if progress is not None:
|
||||||
|
await progress_cb(progress)
|
||||||
|
await self._proc.wait()
|
||||||
|
success = self._proc.returncode == 0
|
||||||
|
if progress_cb is not None:
|
||||||
|
phase = "done" if success else "error"
|
||||||
|
msg = "Завершено" if success else "Ошибка"
|
||||||
|
await progress_cb(FlashProgress(phase=phase, percent=100, message=msg))
|
||||||
|
return success
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Subprocess error [%s]: %s", label, exc)
|
||||||
|
if progress_cb is not None:
|
||||||
|
await progress_cb(
|
||||||
|
FlashProgress(phase="error", percent=0, message=str(exc))
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_progress(line: str) -> Optional[FlashProgress]:
|
||||||
|
"""
|
||||||
|
Извлечь прогресс из строки stdout flash_usb.py.
|
||||||
|
Возвращает None если строка не несёт прогресс-информации.
|
||||||
|
"""
|
||||||
|
percent_m = _RE_PERCENT.search(line)
|
||||||
|
phase_m = _RE_PHASE.search(line)
|
||||||
|
|
||||||
|
if percent_m is None and phase_m is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
percent = int(percent_m.group(1)) if percent_m else 0
|
||||||
|
phase = phase_m.group(1).lower() if phase_m else "flash"
|
||||||
|
return FlashProgress(phase=phase, percent=percent, message=line.strip())
|
||||||
188
tools/production/app/m5_client.py
Normal file
188
tools/production/app/m5_client.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""
|
||||||
|
m5_client.py — клиент M5StampPLC для TUI.
|
||||||
|
|
||||||
|
Тонкая async-обёртка над синхронным JSON-lines протоколом M5 агента.
|
||||||
|
Blocking-вызовы выполняются в executor чтобы не блокировать Textual.
|
||||||
|
|
||||||
|
Поддерживаемые команды агента (JSON-lines):
|
||||||
|
{"cmd": "ping"}
|
||||||
|
{"cmd": "relay_set", "relay": 1..4, "state": true/false}
|
||||||
|
{"cmd": "relay_get", "relay": 1..4}
|
||||||
|
{"cmd": "can_send", "id": 0x100, "data": [0xDE, 0xAD, 0xBE, 0xEF]}
|
||||||
|
{"cmd": "can_recv", "timeout_ms": 500}
|
||||||
|
|
||||||
|
Карта реле (из HIL_BENCH.md):
|
||||||
|
RLY1 — питание таргета (VIN)
|
||||||
|
RLY2 — RS_RX (оптовход RS)
|
||||||
|
RLY3 — EXT_IN1 (оптовход IN1)
|
||||||
|
RLY4 — EXT_IN2 (оптовход IN2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# VID/PID M5StampPLC
|
||||||
|
_M5_VID = int(os.environ.get("SERVICE_M5_VID", "0x303A"), 16)
|
||||||
|
_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)
|
||||||
|
|
||||||
|
_READLINE_TIMEOUT_S = 0.5
|
||||||
|
_CMD_TIMEOUT_S = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def _find_m5_port() -> Optional[str]:
|
||||||
|
"""Автодетект M5StampPLC по VID/PID."""
|
||||||
|
for info in serial.tools.list_ports.comports():
|
||||||
|
if info.vid == _M5_VID and info.pid == _M5_PID:
|
||||||
|
return info.device
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class M5Client:
|
||||||
|
"""
|
||||||
|
Async-клиент для M5StampPLC.
|
||||||
|
|
||||||
|
Пример::
|
||||||
|
|
||||||
|
m5 = await M5Client.auto_connect()
|
||||||
|
if m5:
|
||||||
|
await m5.relay_set(3, True) # RLY3 ON → EXT_IN1 ACTIVE
|
||||||
|
await m5.relay_set(3, False) # RLY3 OFF
|
||||||
|
await m5.disconnect()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 115200) -> None:
|
||||||
|
self._port = port
|
||||||
|
self._baudrate = baudrate
|
||||||
|
self._ser: Optional[serial.Serial] = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def auto_connect(cls, baudrate: int = 115200) -> Optional["M5Client"]:
|
||||||
|
"""
|
||||||
|
Попытаться найти и подключиться к M5StampPLC.
|
||||||
|
Вернуть None если не найден — M5 опционален.
|
||||||
|
"""
|
||||||
|
port = _find_m5_port()
|
||||||
|
if port is None:
|
||||||
|
logger.info("M5StampPLC не найден")
|
||||||
|
return None
|
||||||
|
client = cls(port=port, baudrate=baudrate)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
return client
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("M5StampPLC connect failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Открыть порт и проверить связь через ping."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, self._open)
|
||||||
|
ok = await self.ping()
|
||||||
|
if not ok:
|
||||||
|
await self.disconnect()
|
||||||
|
raise ConnectionError(f"M5StampPLC не отвечает: {self._port}")
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
self._ser = serial.Serial(
|
||||||
|
port=self._port,
|
||||||
|
baudrate=self._baudrate,
|
||||||
|
timeout=_READLINE_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
self._ser.reset_input_buffer()
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, self._close)
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
if self._ser and self._ser.is_open:
|
||||||
|
self._ser.close()
|
||||||
|
self._ser = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._ser is not None and self._ser.is_open
|
||||||
|
|
||||||
|
# ── Low-level I/O ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _send_recv(self, cmd: dict) -> Optional[dict]:
|
||||||
|
"""Отправить команду, прочитать ответ (blocking)."""
|
||||||
|
assert self._ser is not None
|
||||||
|
line = json.dumps(cmd, separators=(",", ":")) + "\n"
|
||||||
|
self._ser.write(line.encode("utf-8"))
|
||||||
|
self._ser.flush()
|
||||||
|
raw = self._ser.readline()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8", errors="replace").strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _cmd(self, cmd: dict) -> Optional[dict]:
|
||||||
|
"""Async wrapper над _send_recv."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
async with self._lock:
|
||||||
|
return await loop.run_in_executor(None, self._send_recv, cmd)
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def ping(self) -> bool:
|
||||||
|
"""Проверить связь с агентом."""
|
||||||
|
resp = await self._cmd({"cmd": "ping"})
|
||||||
|
return resp is not None and resp.get("ok") is True
|
||||||
|
|
||||||
|
async def relay_set(self, relay: int, state: bool) -> bool:
|
||||||
|
"""
|
||||||
|
Переключить реле.
|
||||||
|
|
||||||
|
:param relay: Номер реле 1..4 (в протоколе агента — поле "ch").
|
||||||
|
:param state: True = ON, False = OFF.
|
||||||
|
:return: True при успехе.
|
||||||
|
"""
|
||||||
|
resp = await self._cmd({"cmd": "relay_set", "ch": relay, "state": state})
|
||||||
|
return resp is not None and resp.get("ok") is True
|
||||||
|
|
||||||
|
async def relay_get(self, relay: int) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
Прочитать состояние реле.
|
||||||
|
|
||||||
|
:return: True/False или None при ошибке.
|
||||||
|
"""
|
||||||
|
resp = await self._cmd({"cmd": "relay_get", "ch": relay})
|
||||||
|
if resp and resp.get("ok") is True:
|
||||||
|
return bool(resp.get("state"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def can_send(self, can_id: int, data: list[int]) -> bool:
|
||||||
|
"""Отправить CAN-фрейм через M5."""
|
||||||
|
resp = await self._cmd({"cmd": "can_send", "id": can_id, "data": data})
|
||||||
|
return resp is not None and resp.get("ok") is True
|
||||||
|
|
||||||
|
async def can_recv(self, timeout_ms: int = 500) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Принять CAN-фрейм.
|
||||||
|
|
||||||
|
:return: {"id": int, "data": list[int]} или None при таймауте/ошибке.
|
||||||
|
"""
|
||||||
|
resp = await self._cmd({"cmd": "can_recv", "timeout_ms": timeout_ms})
|
||||||
|
if resp and resp.get("ok") is True:
|
||||||
|
return {"id": resp["id"], "data": resp["data"]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_port() -> Optional[str]:
|
||||||
|
"""Найти порт M5StampPLC. None если не найден."""
|
||||||
|
return _find_m5_port()
|
||||||
136
tools/production/app/models.py
Normal file
136
tools/production/app/models.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""
|
||||||
|
models.py — типы данных TUI сервисного инженера.
|
||||||
|
|
||||||
|
Все модели — frozen dataclasses или IntEnum. Без бизнес-логики.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AppMode(Enum):
|
||||||
|
"""Режим работы приложения — определяется автодетектом USB."""
|
||||||
|
|
||||||
|
WAITING = auto() # ждём подключения устройства
|
||||||
|
FLASHING = auto() # обнаружен BootROM SDP (1FC9:0130)
|
||||||
|
DIAGNOSING = auto() # получен session_start по CDC
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus(Enum):
|
||||||
|
"""Статус выполнения теста."""
|
||||||
|
|
||||||
|
PENDING = auto()
|
||||||
|
RUNNING = auto()
|
||||||
|
PASS = auto()
|
||||||
|
FAIL = auto()
|
||||||
|
SKIP = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class FlashTarget(Enum):
|
||||||
|
"""Что прошиваем."""
|
||||||
|
|
||||||
|
FIRMWARE_TEST = "firmware_test"
|
||||||
|
PRODUCTION = "production" # bootloader + tft_app
|
||||||
|
CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно
|
||||||
|
|
||||||
|
|
||||||
|
class FcbVariant(str, Enum):
|
||||||
|
"""Вариант FCB для кастомных бинарей.
|
||||||
|
|
||||||
|
W25Q128/W25Q64 (3-байтная адресация) и W25Q256/W25Q512 (4-байтная)
|
||||||
|
сведены к двум случаям — см. обсуждение прошивки старых плат.
|
||||||
|
"""
|
||||||
|
|
||||||
|
W25Q128 = "w25q128"
|
||||||
|
W25Q512 = "w25q512"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fcb_filename(self) -> str:
|
||||||
|
"""Имя файла в tools/host/dcd/, соответствующее варианту."""
|
||||||
|
return f"{self.value}_fdcb.bin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return {
|
||||||
|
FcbVariant.W25Q128: "W25Q128 / W25Q64",
|
||||||
|
FcbVariant.W25Q512: "W25Q256 / W25Q512",
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlashPreset:
|
||||||
|
"""«Липкий» выбор оператора на FlashScreen.
|
||||||
|
|
||||||
|
Живёт в памяти ServiceApp (не на диске), переносится на следующую
|
||||||
|
плату в рамках одного запуска TUI — чтобы не выбирать заново файл
|
||||||
|
и опции при прошивке партии одинаковых плат. Сбрасывается при
|
||||||
|
перезапуске TUI. Обновляется в момент нажатия «Загрузить» (не только
|
||||||
|
при успехе — неудача чаще всего про USB-кабель, а не про то, что
|
||||||
|
выбор был неверным).
|
||||||
|
|
||||||
|
DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target: FlashTarget = FlashTarget.FIRMWARE_TEST
|
||||||
|
custom_bin_name: Optional[str] = None
|
||||||
|
use_dcd: bool = False
|
||||||
|
fcb_variant: FcbVariant = FcbVariant.W25Q128
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TestInfo:
|
||||||
|
"""Метаданные теста из list_tests."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
critical: bool
|
||||||
|
requires_hil: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestResult:
|
||||||
|
"""Результат выполнения теста."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
status: TestStatus
|
||||||
|
duration_ms: int = 0
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionState:
|
||||||
|
"""Состояние текущей диагностической сессии."""
|
||||||
|
|
||||||
|
fw_version: str = ""
|
||||||
|
target: str = ""
|
||||||
|
chip_uid: str = ""
|
||||||
|
m5_connected: bool = False
|
||||||
|
tests: list[TestInfo] = field(default_factory=list)
|
||||||
|
results: dict[str, TestResult] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_result(self, test_id: str) -> Optional[TestResult]:
|
||||||
|
return self.results.get(test_id)
|
||||||
|
|
||||||
|
def set_result(self, result: TestResult) -> None:
|
||||||
|
self.results[result.id] = result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfirmRequest:
|
||||||
|
"""confirm_request от таргета."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
prompt: str
|
||||||
|
timeout_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FlashProgress:
|
||||||
|
"""Прогресс прошивки."""
|
||||||
|
|
||||||
|
phase: str # "sdphost" | "blhost" | "done" | "error"
|
||||||
|
percent: int # 0..100
|
||||||
|
message: str
|
||||||
404
tools/production/app/orchestrator.py
Normal file
404
tools/production/app/orchestrator.py
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
"""
|
||||||
|
orchestrator.py — оркестратор confirm_request для TUI.
|
||||||
|
|
||||||
|
Получает события от FirmwareClient, принимает решение кто должен
|
||||||
|
ответить на confirm_request, и отправляет confirm обратно.
|
||||||
|
|
||||||
|
Три режима confirm:
|
||||||
|
|
||||||
|
standalone/interactive (display, mqs, usd):
|
||||||
|
→ TUI показывает prompt оператору + кнопки OK/FAIL + countdown
|
||||||
|
→ ждёт действия оператора
|
||||||
|
→ отправляет confirm
|
||||||
|
|
||||||
|
standalone/buttons:
|
||||||
|
→ TUI показывает инструкцию оператору
|
||||||
|
→ НЕ отправляет confirm — таргет сам детектирует нажатие
|
||||||
|
→ ждёт следующего confirm_request или test_result
|
||||||
|
|
||||||
|
HIL (opto, can):
|
||||||
|
→ TUI автоматически командует M5 через M5Client
|
||||||
|
→ отправляет confirm без участия оператора
|
||||||
|
→ оператор видит только прогресс
|
||||||
|
|
||||||
|
Маршрутизация определяется по id confirm_request:
|
||||||
|
"opto_*" → HIL (M5 relay)
|
||||||
|
"can_rx_ready" → HIL (M5 can_send)
|
||||||
|
"can_tx_verify" → HIL (M5 can_recv + verify)
|
||||||
|
"btn*" → buttons (нет confirm, ждём test_result)
|
||||||
|
всё остальное → operator (показать prompt)
|
||||||
|
|
||||||
|
Помимо confirm_request, протокол v2 определяет "progress" — внутришаговые
|
||||||
|
информационные события долгих тестов (сейчас только usd: card_detect,
|
||||||
|
mount, write, read_compare — см. docs/testing/PROTOCOL.md). Эти события
|
||||||
|
не требуют ответа, только отображаются как текущая фаза прогресса.
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
Orchestrator.run_tests(test_ids) — запустить тесты, yield OrchestratorEvent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import AsyncGenerator, Callable, Coroutine, Optional
|
||||||
|
|
||||||
|
from .firmware_client import FirmwareClient
|
||||||
|
from .m5_client import M5Client
|
||||||
|
from .models import ConfirmRequest, TestResult, TestStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Задержки для HIL
|
||||||
|
_RELAY_ON_S = 0.15
|
||||||
|
_RELAY_OFF_S = 0.50
|
||||||
|
|
||||||
|
# Карта confirm_id → реле M5 для opto-теста
|
||||||
|
# Формат: confirm_id → (relay_num, target_state)
|
||||||
|
_OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
|
||||||
|
"opto_in1_active": (3, True),
|
||||||
|
"opto_in1_inactive": (3, False),
|
||||||
|
"opto_in2_active": (4, True),
|
||||||
|
"opto_in2_inactive": (4, False),
|
||||||
|
"opto_rs_active": (2, True),
|
||||||
|
"opto_rs_inactive": (2, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# CAN параметры
|
||||||
|
_CAN_RX_ID = 0x100
|
||||||
|
_CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
|
||||||
|
_CAN_TX_ID = 0x200
|
||||||
|
_CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE]
|
||||||
|
|
||||||
|
# Тип события от FirmwareClient._recv_until() сигнализирующий таймаут чтения
|
||||||
|
_TIMEOUT_EVENT_TYPE = "_timeout"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Типы событий оркестратора ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestratorEventType(Enum):
|
||||||
|
TEST_BEGIN = auto() # тест начался
|
||||||
|
TEST_RESULT = auto() # тест завершился
|
||||||
|
TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.)
|
||||||
|
CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone)
|
||||||
|
CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически
|
||||||
|
BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm)
|
||||||
|
SUMMARY = auto() # итог всей сессии
|
||||||
|
ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrchestratorEvent:
|
||||||
|
"""Событие от оркестратора — передаётся в TUI."""
|
||||||
|
|
||||||
|
type: OrchestratorEventType
|
||||||
|
test_id: str = ""
|
||||||
|
test_name: str = ""
|
||||||
|
result: Optional[TestResult] = None
|
||||||
|
confirm: Optional[ConfirmRequest] = None
|
||||||
|
summary: Optional[dict] = None
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# Тип callback для ответа оператора
|
||||||
|
OperatorConfirmCallback = Callable[[bool], Coroutine]
|
||||||
|
|
||||||
|
|
||||||
|
class Orchestrator:
|
||||||
|
"""
|
||||||
|
Оркестратор confirm_request.
|
||||||
|
|
||||||
|
Пример::
|
||||||
|
|
||||||
|
orch = Orchestrator(firmware_client, m5_client)
|
||||||
|
async for event in orch.run_tests(["sdram", "opto", "display"]):
|
||||||
|
if event.type == OrchestratorEventType.CONFIRM_NEEDED:
|
||||||
|
confirmed = await tui.ask_operator(event.confirm)
|
||||||
|
await orch.resolve_operator_confirm(confirmed)
|
||||||
|
elif event.type == OrchestratorEventType.TEST_RESULT:
|
||||||
|
tui.update_result(event.result)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
firmware: FirmwareClient,
|
||||||
|
m5: Optional[M5Client] = None,
|
||||||
|
) -> None:
|
||||||
|
self._fw = firmware
|
||||||
|
self._m5 = m5
|
||||||
|
self._operator_queue: asyncio.Queue[bool] = asyncio.Queue(maxsize=1)
|
||||||
|
|
||||||
|
async def resolve_operator_confirm(self, confirmed: bool) -> None:
|
||||||
|
"""
|
||||||
|
TUI вызывает этот метод когда оператор нажал OK или FAIL.
|
||||||
|
Разблокирует ожидание внутри run_tests().
|
||||||
|
"""
|
||||||
|
await self._operator_queue.put(confirmed)
|
||||||
|
|
||||||
|
async def run_tests(
|
||||||
|
self, test_ids: list[str]
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
"""
|
||||||
|
Запустить тесты и оркестрировать confirm_request.
|
||||||
|
|
||||||
|
Yields OrchestratorEvent в порядке поступления событий от firmware_test.
|
||||||
|
Блокируется на CONFIRM_NEEDED до вызова resolve_operator_confirm().
|
||||||
|
|
||||||
|
Гарантия для вызывающего кода: генератор ВСЕГДА заканчивается событием
|
||||||
|
SUMMARY — либо настоящим (от firmware), либо синтетическим при обрыве
|
||||||
|
потока (таймаут чтения порта истёк раньше, чем пришёл summary).
|
||||||
|
Без этой гарантии TUI не может надёжно понять, что прогон завершился
|
||||||
|
(именно это вызывало зависание кнопок при таймауте — см. отчёт).
|
||||||
|
"""
|
||||||
|
current_test_id = ""
|
||||||
|
got_summary = False
|
||||||
|
|
||||||
|
async for raw in self._fw.run_selected(test_ids):
|
||||||
|
event_type = raw.get("type", "")
|
||||||
|
|
||||||
|
if event_type == "test_begin":
|
||||||
|
current_test_id = raw.get("id", "")
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.TEST_BEGIN,
|
||||||
|
test_id=current_test_id,
|
||||||
|
test_name=raw.get("name", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "test_result":
|
||||||
|
status = {
|
||||||
|
"pass": TestStatus.PASS,
|
||||||
|
"fail": TestStatus.FAIL,
|
||||||
|
"skip": TestStatus.SKIP,
|
||||||
|
}.get(raw.get("status", "fail"), TestStatus.FAIL)
|
||||||
|
|
||||||
|
result = TestResult(
|
||||||
|
id=raw.get("id", ""),
|
||||||
|
status=status,
|
||||||
|
duration_ms=raw.get("ms", 0),
|
||||||
|
detail=raw.get("detail", ""),
|
||||||
|
)
|
||||||
|
current_test_id = ""
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.TEST_RESULT,
|
||||||
|
test_id=result.id,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "progress":
|
||||||
|
# Внутришаговый прогресс долгого теста (сейчас только usd).
|
||||||
|
# Не ошибка — информационное событие, не требует ответа.
|
||||||
|
step = raw.get("step", "")
|
||||||
|
pstat = raw.get("status", "")
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.TEST_PROGRESS,
|
||||||
|
test_id=raw.get("test", current_test_id),
|
||||||
|
message=f"{step}: {pstat}" if step else "progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "confirm_request":
|
||||||
|
confirm = ConfirmRequest(
|
||||||
|
id=raw.get("id", ""),
|
||||||
|
prompt=raw.get("prompt", ""),
|
||||||
|
timeout_ms=raw.get("timeout_ms", 30000),
|
||||||
|
)
|
||||||
|
async for ev in self._handle_confirm(confirm):
|
||||||
|
yield ev
|
||||||
|
|
||||||
|
elif event_type == "summary":
|
||||||
|
got_summary = True
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.SUMMARY,
|
||||||
|
summary=raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == _TIMEOUT_EVENT_TYPE:
|
||||||
|
# Чтение порта оборвалось по таймауту раньше, чем пришёл
|
||||||
|
# summary. Завершаем текущий тест (если он был в RUNNING)
|
||||||
|
# синтетическим FAIL, чтобы таблица результатов не осталась
|
||||||
|
# с тестом, навечно подвисшим в "выполняется".
|
||||||
|
timeout_s = raw.get("timeout_s", 0)
|
||||||
|
logger.error(
|
||||||
|
"Test run aborted: read timeout after %.0fs, current_test=%r",
|
||||||
|
timeout_s,
|
||||||
|
current_test_id,
|
||||||
|
)
|
||||||
|
if current_test_id:
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.TEST_RESULT,
|
||||||
|
test_id=current_test_id,
|
||||||
|
result=TestResult(
|
||||||
|
id=current_test_id,
|
||||||
|
status=TestStatus.FAIL,
|
||||||
|
duration_ms=0,
|
||||||
|
detail=f"таймаут связи ({timeout_s:.0f}с)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.ERROR,
|
||||||
|
message=f"Связь с платой прервана (таймаут {timeout_s:.0f}с)",
|
||||||
|
)
|
||||||
|
# break, не return — нужно дойти до synthetic summary ниже
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Неизвестный, но не критичный тип события — логируем для
|
||||||
|
# разработчика, не показываем оператору как ошибку (см.
|
||||||
|
# отчёт замечание №2: ранее "progress" ошибочно считался
|
||||||
|
# неизвестным событием до того как протокол был сверен).
|
||||||
|
logger.debug("Unhandled protocol event: %r", raw)
|
||||||
|
|
||||||
|
if not got_summary:
|
||||||
|
# Поток событий оборвался (таймаут или обрыв соединения) без
|
||||||
|
# настоящего summary от firmware — синтезируем его, чтобы
|
||||||
|
# вызывающий код (DiagScreen._run_worker) гарантированно вышел
|
||||||
|
# из ожидания и разблокировал кнопки/чекбоксы.
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.SUMMARY,
|
||||||
|
summary={"overall": "fail", "passed": 0, "failed": 0, "aborted": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Маршрутизация confirm ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_confirm(
|
||||||
|
self, confirm: ConfirmRequest
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
"""Определить тип confirm и обработать соответственно."""
|
||||||
|
cid = confirm.id
|
||||||
|
|
||||||
|
if cid in _OPTO_RELAY_MAP:
|
||||||
|
async for ev in self._handle_hil_opto(confirm):
|
||||||
|
yield ev
|
||||||
|
return
|
||||||
|
|
||||||
|
if cid == "can_rx_ready":
|
||||||
|
async for ev in self._handle_hil_can_rx(confirm):
|
||||||
|
yield ev
|
||||||
|
return
|
||||||
|
|
||||||
|
if cid == "can_tx_verify":
|
||||||
|
async for ev in self._handle_hil_can_tx(confirm):
|
||||||
|
yield ev
|
||||||
|
return
|
||||||
|
|
||||||
|
if cid.startswith("btn"):
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.BUTTONS_PROMPT,
|
||||||
|
confirm=confirm,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async for ev in self._handle_operator_confirm(confirm):
|
||||||
|
yield ev
|
||||||
|
|
||||||
|
# ── HIL opto ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_hil_opto(
|
||||||
|
self, confirm: ConfirmRequest
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
relay_num, relay_state = _OPTO_RELAY_MAP[confirm.id]
|
||||||
|
|
||||||
|
if self._m5 is None:
|
||||||
|
logger.error("HIL confirm без M5: %s", confirm.id)
|
||||||
|
await self._fw.send_confirm(confirm.id, False)
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.ERROR,
|
||||||
|
message=f"M5 не подключён для HIL confirm: {confirm.id}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
settle_s = _RELAY_ON_S if relay_state else _RELAY_OFF_S
|
||||||
|
ok = await self._m5.relay_set(relay_num, relay_state)
|
||||||
|
await asyncio.sleep(settle_s)
|
||||||
|
await self._fw.send_confirm(confirm.id, ok)
|
||||||
|
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.CONFIRM_RESOLVED,
|
||||||
|
test_id="opto",
|
||||||
|
message=f"RLY{relay_num} {'ON' if relay_state else 'OFF'} → {confirm.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HIL CAN RX ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_hil_can_rx(
|
||||||
|
self, confirm: ConfirmRequest
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
if self._m5 is None:
|
||||||
|
await self._fw.send_confirm(confirm.id, False)
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.ERROR,
|
||||||
|
message="M5 не подключён для CAN RX confirm",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ok = await self._m5.can_send(_CAN_RX_ID, _CAN_RX_DATA)
|
||||||
|
await self._fw.send_confirm(confirm.id, ok)
|
||||||
|
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.CONFIRM_RESOLVED,
|
||||||
|
test_id="can",
|
||||||
|
message=f"M5 CAN TX id=0x{_CAN_RX_ID:03X} data={_CAN_RX_DATA}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HIL CAN TX verify ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_hil_can_tx(
|
||||||
|
self, confirm: ConfirmRequest
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
if self._m5 is None:
|
||||||
|
await self._fw.send_confirm(confirm.id, False)
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.ERROR,
|
||||||
|
message="M5 не подключён для CAN TX verify",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
frame = await self._m5.can_recv(timeout_ms=500)
|
||||||
|
verified = (
|
||||||
|
frame is not None
|
||||||
|
and frame["id"] == _CAN_TX_ID
|
||||||
|
and frame["data"] == _CAN_TX_DATA
|
||||||
|
)
|
||||||
|
await self._fw.send_confirm(confirm.id, verified)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"M5 CAN RX ok: id=0x{_CAN_TX_ID:03X}"
|
||||||
|
if verified
|
||||||
|
else "M5 CAN RX: фрейм не получен или не совпадает"
|
||||||
|
)
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.CONFIRM_RESOLVED,
|
||||||
|
test_id="can",
|
||||||
|
message=msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Operator confirm ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_operator_confirm(
|
||||||
|
self, confirm: ConfirmRequest
|
||||||
|
) -> AsyncGenerator[OrchestratorEvent, None]:
|
||||||
|
"""
|
||||||
|
Передать confirm оператору. Блокируется до resolve_operator_confirm().
|
||||||
|
TUI должен показать prompt и дать возможность ответить.
|
||||||
|
"""
|
||||||
|
while not self._operator_queue.empty():
|
||||||
|
self._operator_queue.get_nowait()
|
||||||
|
|
||||||
|
yield OrchestratorEvent(
|
||||||
|
type=OrchestratorEventType.CONFIRM_NEEDED,
|
||||||
|
confirm=confirm,
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout_s = confirm.timeout_ms / 1000.0
|
||||||
|
try:
|
||||||
|
confirmed = await asyncio.wait_for(
|
||||||
|
self._operator_queue.get(),
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Operator confirm timeout: %s", confirm.id)
|
||||||
|
confirmed = False
|
||||||
|
|
||||||
|
await self._fw.send_confirm(confirm.id, confirmed)
|
||||||
8
tools/production/app/screens/__init__.py
Normal file
8
tools/production/app/screens/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""screens — публичный экспорт экранов TUI."""
|
||||||
|
|
||||||
|
from .diag import DiagScreen
|
||||||
|
from .flash import FlashScreen
|
||||||
|
from .post_flash import PostFlashScreen
|
||||||
|
from .waiting import WaitingScreen
|
||||||
|
|
||||||
|
__all__ = ["WaitingScreen", "FlashScreen", "PostFlashScreen", "DiagScreen"]
|
||||||
78
tools/production/app/screens/connection_watcher.py
Normal file
78
tools/production/app/screens/connection_watcher.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""
|
||||||
|
connection_watcher.py — мониторинг USB-соединения для экранов FlashScreen
|
||||||
|
и DiagScreen.
|
||||||
|
|
||||||
|
Периодически (каждые _WATCH_INTERVAL_S) проверяет, виден ли таргет ещё
|
||||||
|
на шине USB. При потере соединения — экран обязан немедленно прекратить
|
||||||
|
любое взаимодействие (кроме кнопки "Выйти") и вернуться на WaitingScreen.
|
||||||
|
Решение архитектурно: при разрыве сессия считается недостоверной и
|
||||||
|
не восстанавливается — экран не пытается определить "вернулась ли та же
|
||||||
|
самая плата", просто стартует заново с нуля.
|
||||||
|
|
||||||
|
Используется как миксин: класс экрана наследует ConnectionWatcherMixin
|
||||||
|
вторым родителем после Screen, вызывает self._start_connection_watch(check_fn)
|
||||||
|
в on_mount(), и переопределяет _on_connection_lost() для специфичной
|
||||||
|
очистки экрана перед уходом на WaitingScreen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.timer import Timer
|
||||||
|
|
||||||
|
_WATCH_INTERVAL_S = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionLost(Message):
|
||||||
|
"""
|
||||||
|
Соединение с платой потеряно во время нахождения на экране.
|
||||||
|
Экран должен прекратить взаимодействие и вернуться на WaitingScreen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionWatcherMixin:
|
||||||
|
"""
|
||||||
|
Миксин периодической проверки USB-соединения.
|
||||||
|
|
||||||
|
Использование в экране::
|
||||||
|
|
||||||
|
class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._start_connection_watch(Flasher.detect_sdp)
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._stop_connection_watch()
|
||||||
|
"""
|
||||||
|
|
||||||
|
_connection_watch_timer: Timer | None = None
|
||||||
|
_connection_lost: bool = False
|
||||||
|
|
||||||
|
def _start_connection_watch(self, check_fn: Callable[[], bool]) -> None:
|
||||||
|
"""
|
||||||
|
Запустить периодическую проверку. check_fn должна вернуть True
|
||||||
|
пока устройство видно на шине (Flasher.detect_sdp / detect_cdc).
|
||||||
|
"""
|
||||||
|
self._connection_lost = False
|
||||||
|
self._connection_check_fn = check_fn
|
||||||
|
self._connection_watch_timer = self.set_interval( # type: ignore[attr-defined]
|
||||||
|
_WATCH_INTERVAL_S, self._check_connection
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stop_connection_watch(self) -> None:
|
||||||
|
if self._connection_watch_timer is not None:
|
||||||
|
self._connection_watch_timer.stop()
|
||||||
|
self._connection_watch_timer = None
|
||||||
|
|
||||||
|
def _check_connection(self) -> None:
|
||||||
|
if self._connection_lost:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
still_present = self._connection_check_fn()
|
||||||
|
except Exception:
|
||||||
|
still_present = False
|
||||||
|
if not still_present:
|
||||||
|
self._connection_lost = True
|
||||||
|
self._stop_connection_watch()
|
||||||
|
self.post_message(ConnectionLost()) # type: ignore[attr-defined]
|
||||||
334
tools/production/app/screens/diag/__init__.py
Normal file
334
tools/production/app/screens/diag/__init__.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""
|
||||||
|
diag/__init__.py — экран диагностики (режим B).
|
||||||
|
|
||||||
|
DiagScreen координирует три виджета:
|
||||||
|
TestListPanel — выбор тестов (левая колонка)
|
||||||
|
ResultsPanel — результаты (правая колонка)
|
||||||
|
ConfirmPanel — confirm_request оператора (нижняя панель)
|
||||||
|
|
||||||
|
и Orchestrator — маршрутизатор confirm_request.
|
||||||
|
|
||||||
|
Мониторинг соединения (см. отчёт, замечание №4): пока тесты не запущены,
|
||||||
|
каждые 1.5с проверяется наличие CDC-порта на шине. Во время активного
|
||||||
|
прогона мониторинг приостановлен — таймаут чтения порта внутри Orchestrator
|
||||||
|
уже детектирует обрыв связи надёжнее (видит реальную остановку потока
|
||||||
|
данных, а не просто исчезновение устройства из списка портов) и сам
|
||||||
|
формирует понятный результат для прерванного теста.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.css.query import NoMatches
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Button, Label, ProgressBar, Static
|
||||||
|
|
||||||
|
from ...firmware_client import FirmwareClient
|
||||||
|
from ...flasher import Flasher
|
||||||
|
from ...m5_client import M5Client
|
||||||
|
from ...models import SessionState
|
||||||
|
from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType
|
||||||
|
from ...widgets import AppFrame
|
||||||
|
from ..connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
||||||
|
from .confirm_panel import ConfirmPanel
|
||||||
|
from .results import ResultsPanel
|
||||||
|
from .test_list import TestListPanel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagScreen(Screen, ConnectionWatcherMixin):
|
||||||
|
"""
|
||||||
|
Экран диагностики.
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
DiagDone() — сессия завершена (плата отключена или summary получен)
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "go_back", "Отключиться"),
|
||||||
|
Binding("r", "run_all", "Все тесты"),
|
||||||
|
Binding("s", "run_selected", "Выбранные"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class DiagDone(Message):
|
||||||
|
"""Сессия диагностики завершена."""
|
||||||
|
|
||||||
|
def __init__(self, reason: Optional[str] = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
firmware: FirmwareClient,
|
||||||
|
m5: Optional[M5Client] = None,
|
||||||
|
fw_version: str = "",
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._fw = firmware
|
||||||
|
self._m5 = m5
|
||||||
|
self._orchestrator = Orchestrator(firmware, m5)
|
||||||
|
self._session = SessionState(fw_version=fw_version)
|
||||||
|
self._tests_running = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with AppFrame(id="diag-frame"):
|
||||||
|
# Шапка — фиксированная высота 3
|
||||||
|
with Horizontal(id="diag-header"):
|
||||||
|
yield Static("fw: —", id="diag-header-fw")
|
||||||
|
yield Static("MCU ID: —", id="diag-header-uid")
|
||||||
|
yield Static("M5 Bench: —", id="diag-header-m5")
|
||||||
|
|
||||||
|
# Рабочая зона: список тестов + результаты — занимает всё
|
||||||
|
# оставшееся место (1fr), сама прокручивается при переполнении
|
||||||
|
with Horizontal(id="diag-main"):
|
||||||
|
yield TestListPanel(id="diag-test-list")
|
||||||
|
yield ResultsPanel(id="diag-results")
|
||||||
|
|
||||||
|
# Прогресс — скрыт пока нет активного прогона. Контейнер
|
||||||
|
# имеет classes="hidden" по умолчанию — height: auto + display:
|
||||||
|
# none даёт нулевую высоту, не отнимая место у остального layout.
|
||||||
|
with Vertical(id="diag-progress-row", classes="hidden"):
|
||||||
|
yield ProgressBar(
|
||||||
|
id="diag-progress-bar", show_eta=False, show_percentage=False
|
||||||
|
)
|
||||||
|
yield Label("", id="diag-progress-label")
|
||||||
|
|
||||||
|
# Кнопки запуска — фиксированная высота 3 (под border Button)
|
||||||
|
with Horizontal(id="diag-btn-row"):
|
||||||
|
yield Button(
|
||||||
|
"▶ Запустить выбранные тесты",
|
||||||
|
id="diag-btn-run-selected",
|
||||||
|
variant="primary",
|
||||||
|
disabled=True,
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"▶▶ Запустить все тесты",
|
||||||
|
id="diag-btn-run-all",
|
||||||
|
variant="default",
|
||||||
|
disabled=True,
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"✕ Выйти из приложения", id="diag-btn-quit", variant="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Панель confirm — auto-высота, видна только когда есть запрос
|
||||||
|
yield ConfirmPanel(id="diag-confirm", classes="hidden")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._init_session()
|
||||||
|
self._start_connection_watch(self._check_cdc_present)
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._stop_connection_watch()
|
||||||
|
|
||||||
|
def _check_cdc_present(self) -> bool:
|
||||||
|
# Не считаем потерей соединения во время активного прогона —
|
||||||
|
# Orchestrator сам детектирует обрыв через таймаут чтения порта
|
||||||
|
# (надёжнее: видит остановку потока данных, не просто список USB).
|
||||||
|
if self._tests_running:
|
||||||
|
return True
|
||||||
|
return Flasher.detect_cdc()
|
||||||
|
|
||||||
|
@on(ConnectionLost)
|
||||||
|
def _on_connection_lost(self) -> None:
|
||||||
|
self.post_message(self.DiagDone(reason="Соединение с платой потеряно"))
|
||||||
|
|
||||||
|
# ── Инициализация сессии ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(thread=False)
|
||||||
|
async def _init_session(self) -> None:
|
||||||
|
try:
|
||||||
|
tests = await self._fw.list_tests()
|
||||||
|
uid = await self._fw.get_uid()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Session init failed: %s", exc)
|
||||||
|
self.post_message(self.DiagDone(reason="Не удалось получить список тестов"))
|
||||||
|
return
|
||||||
|
|
||||||
|
self._session.tests = tests
|
||||||
|
self._session.chip_uid = uid
|
||||||
|
self._session.m5_connected = self._m5 is not None
|
||||||
|
|
||||||
|
self._update_header()
|
||||||
|
|
||||||
|
test_list = self.query_one("#diag-test-list", TestListPanel)
|
||||||
|
test_list.populate(tests, m5_connected=self._session.m5_connected)
|
||||||
|
|
||||||
|
self.query_one("#diag-results", ResultsPanel).populate(tests)
|
||||||
|
self._set_run_buttons(enabled=True)
|
||||||
|
|
||||||
|
def _update_header(self) -> None:
|
||||||
|
self.query_one("#diag-header-fw", Static).update(
|
||||||
|
f"fw: {self._session.fw_version or '?'}"
|
||||||
|
)
|
||||||
|
uid = self._session.chip_uid or "—"
|
||||||
|
self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}")
|
||||||
|
|
||||||
|
m5_widget = self.query_one("#diag-header-m5", Static)
|
||||||
|
if self._session.m5_connected:
|
||||||
|
m5_widget.update("M5 Bench: ✓ подключён")
|
||||||
|
m5_widget.remove_class("m5-absent")
|
||||||
|
else:
|
||||||
|
m5_widget.update("M5 Bench: ✕ нет связи")
|
||||||
|
m5_widget.add_class("m5-absent")
|
||||||
|
|
||||||
|
# ── Кнопки ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#diag-btn-run-selected")
|
||||||
|
def _on_run_selected(self) -> None:
|
||||||
|
ids = self.query_one("#diag-test-list", TestListPanel).get_selected_ids()
|
||||||
|
if ids:
|
||||||
|
self._start_run(ids)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#diag-btn-run-all")
|
||||||
|
def _on_run_all(self) -> None:
|
||||||
|
ids = [t.id for t in self._session.tests]
|
||||||
|
if ids:
|
||||||
|
self._start_run(ids)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#diag-btn-quit")
|
||||||
|
def _on_quit_pressed(self) -> None:
|
||||||
|
if not self._tests_running:
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
if not self._tests_running:
|
||||||
|
self.post_message(self.DiagDone())
|
||||||
|
|
||||||
|
def action_run_all(self) -> None:
|
||||||
|
self._on_run_all()
|
||||||
|
|
||||||
|
def action_run_selected(self) -> None:
|
||||||
|
self._on_run_selected()
|
||||||
|
|
||||||
|
# ── Confirm ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(ConfirmPanel.Confirmed)
|
||||||
|
def _on_confirmed(self, event: ConfirmPanel.Confirmed) -> None:
|
||||||
|
"""Оператор ответил — передать в оркестратор."""
|
||||||
|
self.app.call_later(
|
||||||
|
self._orchestrator.resolve_operator_confirm, event.confirmed
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Запуск тестов ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_run(self, test_ids: list[str]) -> None:
|
||||||
|
results = self.query_one("#diag-results", ResultsPanel)
|
||||||
|
results.reset()
|
||||||
|
self._session.results.clear()
|
||||||
|
self._show_progress(True)
|
||||||
|
self._update_progress(0, len(test_ids), "")
|
||||||
|
self._set_run_buttons(enabled=False)
|
||||||
|
self.query_one("#diag-test-list", TestListPanel).set_enabled(False)
|
||||||
|
self._tests_running = True
|
||||||
|
self._run_worker(test_ids)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=False)
|
||||||
|
async def _run_worker(self, test_ids: list[str]) -> None:
|
||||||
|
total = len(test_ids)
|
||||||
|
done = 0
|
||||||
|
try:
|
||||||
|
async for event in self._orchestrator.run_tests(test_ids):
|
||||||
|
self._handle_event(event, total, done)
|
||||||
|
if event.type == OrchestratorEventType.TEST_RESULT:
|
||||||
|
done += 1
|
||||||
|
if event.type == OrchestratorEventType.SUMMARY:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("run_worker error: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._tests_running = False
|
||||||
|
self._set_run_buttons(enabled=True)
|
||||||
|
self.query_one("#diag-test-list", TestListPanel).set_enabled(True)
|
||||||
|
try:
|
||||||
|
self.query_one("#diag-confirm", ConfirmPanel).hide()
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _handle_event(self, event: OrchestratorEvent, total: int, done: int) -> None:
|
||||||
|
results = self.query_one("#diag-results", ResultsPanel)
|
||||||
|
confirm = self.query_one("#diag-confirm", ConfirmPanel)
|
||||||
|
|
||||||
|
if event.type == OrchestratorEventType.TEST_BEGIN:
|
||||||
|
results.set_running(event.test_id)
|
||||||
|
self._update_progress(done, total, f"Тест: {event.test_id}")
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.TEST_RESULT:
|
||||||
|
assert event.result is not None
|
||||||
|
self._session.set_result(event.result)
|
||||||
|
results.set_result(event.result)
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.TEST_PROGRESS:
|
||||||
|
# Внутришаговый прогресс долгого теста (сейчас только usd):
|
||||||
|
# card_detect, mount, write, read_compare — см. PROTOCOL.md.
|
||||||
|
self._update_progress(
|
||||||
|
done, total, f"Тест: {event.test_id} — {event.message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.CONFIRM_NEEDED:
|
||||||
|
assert event.confirm is not None
|
||||||
|
confirm.show_operator(
|
||||||
|
prompt=event.confirm.prompt,
|
||||||
|
timeout_ms=event.confirm.timeout_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.CONFIRM_RESOLVED:
|
||||||
|
self._update_progress(done, total, f"M5 Bench: {event.message}")
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.BUTTONS_PROMPT:
|
||||||
|
assert event.confirm is not None
|
||||||
|
confirm.show_buttons_hint(event.confirm.prompt)
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.SUMMARY:
|
||||||
|
self._on_summary(event.summary or {})
|
||||||
|
|
||||||
|
elif event.type == OrchestratorEventType.ERROR:
|
||||||
|
self._update_progress(done, total, f"⚠ {event.message}")
|
||||||
|
|
||||||
|
def _on_summary(self, summary: dict) -> None:
|
||||||
|
aborted = summary.get("aborted", False)
|
||||||
|
if aborted:
|
||||||
|
self.query_one("#diag-progress-label", Label).update(
|
||||||
|
"⚠ Прогон прерван: связь с платой потеряна"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
overall = summary.get("overall", "fail")
|
||||||
|
passed = summary.get("passed", 0)
|
||||||
|
failed = summary.get("failed", 0)
|
||||||
|
icon = "✅" if overall == "pass" else "❌"
|
||||||
|
self.query_one("#diag-progress-label", Label).update(
|
||||||
|
f"{icon} Итог: {passed} прошли, {failed} не прошли"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Утилиты ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _show_progress(self, visible: bool) -> None:
|
||||||
|
"""Показать/скрыть строку прогресса. Скрыта в простое — без анимации."""
|
||||||
|
row = self.query_one("#diag-progress-row")
|
||||||
|
bar = self.query_one("#diag-progress-bar", ProgressBar)
|
||||||
|
if visible:
|
||||||
|
bar.update(total=100, progress=0)
|
||||||
|
row.remove_class("hidden")
|
||||||
|
else:
|
||||||
|
row.add_class("hidden")
|
||||||
|
|
||||||
|
def _update_progress(self, done: int, total: int, msg: str) -> None:
|
||||||
|
pct = int(done / total * 100) if total else 0
|
||||||
|
self.query_one("#diag-progress-bar", ProgressBar).update(
|
||||||
|
total=100, progress=pct
|
||||||
|
)
|
||||||
|
self.query_one("#diag-progress-label", Label).update(msg)
|
||||||
|
|
||||||
|
def _set_run_buttons(self, enabled: bool) -> None:
|
||||||
|
self.query_one("#diag-btn-run-selected", Button).disabled = not enabled
|
||||||
|
self.query_one("#diag-btn-run-all", Button).disabled = not enabled
|
||||||
135
tools/production/app/screens/diag/confirm_panel.py
Normal file
135
tools/production/app/screens/diag/confirm_panel.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
confirm_panel.py — виджет панели подтверждения оператора.
|
||||||
|
|
||||||
|
Показывается при confirm_request от firmware_test требующем ответа оператора.
|
||||||
|
Скрыт по умолчанию (CSS-класс 'hidden').
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
ConfirmPanel.Confirmed(confirmed: bool) — оператор нажал OK или Нет
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual import on
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.css.query import NoMatches
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.timer import Timer
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import Button, Static
|
||||||
|
|
||||||
|
_TICK_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPanel(Widget):
|
||||||
|
"""
|
||||||
|
Панель подтверждения оператора.
|
||||||
|
|
||||||
|
Использование::
|
||||||
|
|
||||||
|
panel = ConfirmPanel()
|
||||||
|
panel.show_operator(prompt="Экран залит красным?", timeout_ms=30000)
|
||||||
|
# Слушать ConfirmPanel.Confirmed в родительском экране
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Confirmed(Message):
|
||||||
|
"""Оператор ответил на confirm_request."""
|
||||||
|
|
||||||
|
def __init__(self, confirmed: bool) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.confirmed = confirmed
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._timer: Timer | None = None
|
||||||
|
self._remaining: int = 0
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("", id="confirm-prompt")
|
||||||
|
yield Static("", id="confirm-countdown")
|
||||||
|
yield Button("✓ Да", id="confirm-btn-ok", variant="success")
|
||||||
|
yield Button("✗ Нет", id="confirm-btn-fail", variant="error")
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def show_operator(self, prompt: str, timeout_ms: int) -> None:
|
||||||
|
"""Показать панель с кнопками OK/Нет и countdown."""
|
||||||
|
self._remaining = timeout_ms // 1000
|
||||||
|
self._set_prompt(f"⚠ {prompt}")
|
||||||
|
self._set_countdown(self._remaining)
|
||||||
|
self._show_buttons(True)
|
||||||
|
self.remove_class("hidden")
|
||||||
|
self._start_timer()
|
||||||
|
|
||||||
|
def show_buttons_hint(self, prompt: str) -> None:
|
||||||
|
"""
|
||||||
|
Показать инструкцию для теста кнопок.
|
||||||
|
Без кнопок OK/Нет — оператор только читает, нажимает физическую кнопку.
|
||||||
|
"""
|
||||||
|
self._stop_timer()
|
||||||
|
self._set_prompt(f"⌨ {prompt}")
|
||||||
|
self._set_countdown("")
|
||||||
|
self._show_buttons(False)
|
||||||
|
self.remove_class("hidden")
|
||||||
|
|
||||||
|
def hide(self) -> None:
|
||||||
|
"""Скрыть панель, остановить таймер."""
|
||||||
|
self._stop_timer()
|
||||||
|
self.add_class("hidden")
|
||||||
|
self._show_buttons(True) # восстановить на следующий раз
|
||||||
|
|
||||||
|
# ── Обработчики ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#confirm-btn-ok")
|
||||||
|
def _on_ok(self) -> None:
|
||||||
|
self.hide()
|
||||||
|
self.post_message(self.Confirmed(confirmed=True))
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#confirm-btn-fail")
|
||||||
|
def _on_fail(self) -> None:
|
||||||
|
self.hide()
|
||||||
|
self.post_message(self.Confirmed(confirmed=False))
|
||||||
|
|
||||||
|
# ── Таймер countdown ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_timer(self) -> None:
|
||||||
|
self._stop_timer()
|
||||||
|
self._timer = self.set_interval(_TICK_S, self._tick)
|
||||||
|
|
||||||
|
def _stop_timer(self) -> None:
|
||||||
|
if self._timer is not None:
|
||||||
|
self._timer.stop()
|
||||||
|
self._timer = None
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
self._remaining -= 1
|
||||||
|
self._set_countdown(self._remaining)
|
||||||
|
if self._remaining <= 0:
|
||||||
|
self.hide()
|
||||||
|
self.post_message(self.Confirmed(confirmed=False))
|
||||||
|
|
||||||
|
# ── Утилиты ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_prompt(self, text: str) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#confirm-prompt", Static).update(text)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_countdown(self, value: int | str) -> None:
|
||||||
|
text = f"{value}с" if isinstance(value, int) and value > 0 else ""
|
||||||
|
try:
|
||||||
|
self.query_one("#confirm-countdown", Static).update(text)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _show_buttons(self, visible: bool) -> None:
|
||||||
|
for btn_id in ("#confirm-btn-ok", "#confirm-btn-fail"):
|
||||||
|
try:
|
||||||
|
btn = self.query_one(btn_id, Button)
|
||||||
|
if visible:
|
||||||
|
btn.remove_class("hidden")
|
||||||
|
else:
|
||||||
|
btn.add_class("hidden")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
263
tools/production/app/screens/diag/results.py
Normal file
263
tools/production/app/screens/diag/results.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""
|
||||||
|
results.py — виджет правой колонки DiagScreen.
|
||||||
|
|
||||||
|
DataTable с колонками: Тест | HIL | Статус | Время | Детали.
|
||||||
|
До первого запуска показывает empty-state placeholder вместо таблицы.
|
||||||
|
|
||||||
|
Сортировка: FAIL всегда наверху (естественно бросается в глаза),
|
||||||
|
внутри групп статусов — исходный порядок реестра firmware_test.
|
||||||
|
Строка с FAIL дополнительно подсвечивается красным фоном целиком.
|
||||||
|
|
||||||
|
Колонка "Детали" переносит длинный текст на несколько строк внутри ячейки
|
||||||
|
(а не обрезает) — высота строки для FAIL вычисляется по длине detail
|
||||||
|
относительно ширины колонки. Остальные статусы всегда однострочные.
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
ResultsPanel.populate(tests) — показать пустую таблицу (PENDING)
|
||||||
|
ResultsPanel.set_running(test_id) — пометить тест как выполняющийся
|
||||||
|
ResultsPanel.set_result(result) — показать финальный результат
|
||||||
|
ResultsPanel.reset() — сбросить все строки в PENDING
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from rich.style import Style
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Center, Middle
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import DataTable, Static
|
||||||
|
|
||||||
|
from ...models import TestInfo, TestResult, TestStatus
|
||||||
|
|
||||||
|
# Порядок сортировки: чем меньше число — тем выше строка в таблице.
|
||||||
|
# FAIL всегда наверху, PASS/SKIP внизу — внутри групп сохраняется
|
||||||
|
# исходный порядок реестра (стабильная сортировка).
|
||||||
|
_SORT_RANK: dict[TestStatus, int] = {
|
||||||
|
TestStatus.FAIL: 0,
|
||||||
|
TestStatus.RUNNING: 1,
|
||||||
|
TestStatus.PENDING: 2,
|
||||||
|
TestStatus.SKIP: 3,
|
||||||
|
TestStatus.PASS: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
_STATUS_DISPLAY: dict[TestStatus, tuple[str, str]] = {
|
||||||
|
TestStatus.PASS: ("✓ PASS", "green"),
|
||||||
|
TestStatus.FAIL: ("✗ FAIL", "bold white"),
|
||||||
|
TestStatus.RUNNING: ("…", "yellow"),
|
||||||
|
TestStatus.SKIP: ("SKIP", "grey50"),
|
||||||
|
TestStatus.PENDING: ("", "grey50"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_FAIL_BG = "dark_red"
|
||||||
|
|
||||||
|
# Ширина колонки "Детали" в символах — должна совпадать с шириной,
|
||||||
|
# заданной явно в on_mount() через table.add_column("Детали", width=...).
|
||||||
|
# Используется для расчёта высоты строки под перенос текста.
|
||||||
|
_DETAIL_COL_WIDTH = 21
|
||||||
|
_MAX_ROW_HEIGHT = 4 # не даём одной FAIL-строке занять весь экран
|
||||||
|
|
||||||
|
|
||||||
|
class ResultsPanel(Widget):
|
||||||
|
"""Правая колонка DiagScreen — таблица результатов тестов."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
# test_id → TestInfo, нужно для перестроения строк при сортировке
|
||||||
|
self._tests: dict[str, TestInfo] = {}
|
||||||
|
# test_id → текущий статус (для сортировки и повторных update)
|
||||||
|
self._statuses: dict[str, TestStatus] = {}
|
||||||
|
self._order: list[str] = [] # исходный порядок реестра
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Center(id="results-empty"):
|
||||||
|
with Middle():
|
||||||
|
yield Static(
|
||||||
|
"Выберите тесты и нажмите\n«Запустить выбранные»",
|
||||||
|
id="results-empty-text",
|
||||||
|
)
|
||||||
|
yield DataTable(id="results-table", cursor_type="row", classes="hidden")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
# add_columns() (множественное число) не принимает width — без явной
|
||||||
|
# ширины колонка "Детали" сжимается до длины заголовка ("Детали" = 6
|
||||||
|
# символов) и обрезает любой более длинный текст, даже однострочный.
|
||||||
|
# Используем add_column() по одной с явной шириной под каждую,
|
||||||
|
# рассчитанной под ResultsPanel { width: 70 } (см. app.tcss).
|
||||||
|
table.add_column("Тест", width=22)
|
||||||
|
table.add_column("M5 Bench", width=8)
|
||||||
|
table.add_column("Статус", width=8)
|
||||||
|
table.add_column("Время", width=6)
|
||||||
|
table.add_column("Детали", width=21)
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def populate(self, tests: list[TestInfo]) -> None:
|
||||||
|
"""
|
||||||
|
Заполнить таблицу тестами в состоянии PENDING.
|
||||||
|
До первого вызова populate() с непустым списком виден empty-state.
|
||||||
|
"""
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
table.clear()
|
||||||
|
self._tests.clear()
|
||||||
|
self._statuses.clear()
|
||||||
|
self._order = [t.id for t in tests]
|
||||||
|
|
||||||
|
if not tests:
|
||||||
|
self._show_empty(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
self._tests[test.id] = test
|
||||||
|
self._statuses[test.id] = TestStatus.PENDING
|
||||||
|
self._add_row(test, TestStatus.PENDING, duration_ms=0, detail="")
|
||||||
|
|
||||||
|
self._show_empty(False)
|
||||||
|
|
||||||
|
def set_running(self, test_id: str) -> None:
|
||||||
|
"""Пометить тест как выполняющийся."""
|
||||||
|
self._update_row(test_id, TestStatus.RUNNING, duration_ms=0, detail="")
|
||||||
|
|
||||||
|
def set_result(self, result: TestResult) -> None:
|
||||||
|
"""Показать финальный результат теста."""
|
||||||
|
detail = result.detail if result.status == TestStatus.FAIL else ""
|
||||||
|
self._update_row(result.id, result.status, result.duration_ms, detail)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Сбросить все строки в PENDING (перед повторным запуском)."""
|
||||||
|
for test_id in list(self._tests.keys()):
|
||||||
|
self._update_row(test_id, TestStatus.PENDING, duration_ms=0, detail="")
|
||||||
|
|
||||||
|
# ── Internal: empty state ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _show_empty(self, visible: bool) -> None:
|
||||||
|
empty = self.query_one("#results-empty")
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
if visible:
|
||||||
|
empty.remove_class("hidden")
|
||||||
|
table.add_class("hidden")
|
||||||
|
else:
|
||||||
|
empty.add_class("hidden")
|
||||||
|
table.remove_class("hidden")
|
||||||
|
|
||||||
|
# ── Internal: строки таблицы ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _row_height(self, detail: str) -> int:
|
||||||
|
"""
|
||||||
|
Сколько строк нужно ячейке "Детали" под перенос текста.
|
||||||
|
1 строка по умолчанию; растёт пропорционально длине detail,
|
||||||
|
ограничено _MAX_ROW_HEIGHT чтобы один FAIL не съел весь экран
|
||||||
|
(очень длинный detail в этом случае обрежется — лучше, чем
|
||||||
|
одна строка съедает половину видимой таблицы).
|
||||||
|
"""
|
||||||
|
if not detail:
|
||||||
|
return 1
|
||||||
|
needed = math.ceil(len(detail) / _DETAIL_COL_WIDTH)
|
||||||
|
return max(1, min(needed, _MAX_ROW_HEIGHT))
|
||||||
|
|
||||||
|
def _add_row(
|
||||||
|
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
|
||||||
|
) -> None:
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
cells = self._row_cells(test, status, duration_ms, detail)
|
||||||
|
table.add_row(*cells, key=test.id, height=self._row_height(detail))
|
||||||
|
|
||||||
|
def _update_row(
|
||||||
|
self, test_id: str, status: TestStatus, duration_ms: int, detail: str
|
||||||
|
) -> None:
|
||||||
|
test = self._tests.get(test_id)
|
||||||
|
if test is None:
|
||||||
|
return
|
||||||
|
self._statuses[test_id] = status
|
||||||
|
|
||||||
|
table = self.query_one("#results-table", DataTable)
|
||||||
|
cells = self._row_cells(test, status, duration_ms, detail)
|
||||||
|
new_height = self._row_height(detail)
|
||||||
|
|
||||||
|
# DataTable не предоставляет публичный API для изменения высоты
|
||||||
|
# уже добавленной строки (update_cell меняет только содержимое).
|
||||||
|
# Когда нужная высота отличается от текущей (например, тест перешёл
|
||||||
|
# в FAIL с многострочным detail) — пересоздаём строку: remove + add.
|
||||||
|
# Иначе — точечный update_cell, дешевле и не теряет курсор/scroll.
|
||||||
|
current_height = table.get_row_height(test_id)
|
||||||
|
if current_height != new_height:
|
||||||
|
table.remove_row(test_id)
|
||||||
|
table.add_row(*cells, key=test_id, height=new_height)
|
||||||
|
else:
|
||||||
|
col_keys = [c.key for c in table.ordered_columns]
|
||||||
|
for col_key, value in zip(col_keys, cells):
|
||||||
|
table.update_cell(test_id, col_key, value)
|
||||||
|
|
||||||
|
self._resort(table)
|
||||||
|
|
||||||
|
def _row_cells(
|
||||||
|
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
|
||||||
|
) -> tuple:
|
||||||
|
"""Собрать пять ячеек строки с учётом подсветки FAIL и переноса текста."""
|
||||||
|
status_text, status_color = _STATUS_DISPLAY[status]
|
||||||
|
is_fail = status == TestStatus.FAIL
|
||||||
|
|
||||||
|
bg = _FAIL_BG if is_fail else None
|
||||||
|
fg = "white" if is_fail else status_color
|
||||||
|
|
||||||
|
name_style = Style(bgcolor=bg, bold=is_fail)
|
||||||
|
hil_style = Style(bgcolor=bg, color="white" if is_fail else "cyan")
|
||||||
|
status_style = Style(bgcolor=bg, color=fg, bold=True)
|
||||||
|
time_style = Style(bgcolor=bg, color="white" if is_fail else "grey70")
|
||||||
|
detail_style = Style(bgcolor=bg, color="white" if is_fail else "grey50")
|
||||||
|
|
||||||
|
time_text = f"{duration_ms / 1000:.1f}с" if duration_ms > 0 else ""
|
||||||
|
hil_text = "[*]" if test.requires_hil else "[-]"
|
||||||
|
|
||||||
|
# overflow="fold" — перенос по символам на границе ячейки вместо
|
||||||
|
# обрезания с многоточием (Textual default), detail виден целиком
|
||||||
|
# на нескольких строках, если высота строки это позволяет.
|
||||||
|
detail_cell = Text(detail, style=detail_style, overflow="fold")
|
||||||
|
|
||||||
|
return (
|
||||||
|
Text(test.name, style=name_style),
|
||||||
|
Text(hil_text, style=hil_style),
|
||||||
|
Text(status_text, style=status_style),
|
||||||
|
Text(time_text, style=time_style),
|
||||||
|
detail_cell,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resort(self, table: DataTable) -> None:
|
||||||
|
"""
|
||||||
|
Пересортировать: FAIL наверх, дальше RUNNING/PENDING/SKIP/PASS.
|
||||||
|
Внутри групп — исходный порядок реестра (стабильная сортировка).
|
||||||
|
|
||||||
|
DataTable.sort(*columns, key=...) передаёт в key() кортеж значений
|
||||||
|
ЯЧЕЕК (не row_key) для указанных columns — поэтому сортируем по
|
||||||
|
содержимому самой ячейки "Тест" (используем как индекс в self._order)
|
||||||
|
и по тексту статуса, который мы сами туда пишем и полностью
|
||||||
|
контролируем — никаких приватных атрибутов DataTable не трогаем.
|
||||||
|
"""
|
||||||
|
name_col = table.ordered_columns[0].key
|
||||||
|
status_col = table.ordered_columns[2].key
|
||||||
|
|
||||||
|
# text -> status enum, обратное к _STATUS_DISPLAY
|
||||||
|
status_by_text = {text: status for status, (text, _) in _STATUS_DISPLAY.items()}
|
||||||
|
# имя теста -> индекс в исходном реестре (для стабильности внутри группы)
|
||||||
|
name_to_order = {
|
||||||
|
self._tests[tid].name: idx
|
||||||
|
for idx, tid in enumerate(self._order)
|
||||||
|
if tid in self._tests
|
||||||
|
}
|
||||||
|
|
||||||
|
def sort_key(cells: tuple) -> tuple:
|
||||||
|
name_cell, status_cell = cells
|
||||||
|
name_plain = (
|
||||||
|
name_cell.plain if hasattr(name_cell, "plain") else str(name_cell)
|
||||||
|
)
|
||||||
|
status_plain = (
|
||||||
|
status_cell.plain if hasattr(status_cell, "plain") else str(status_cell)
|
||||||
|
)
|
||||||
|
status = status_by_text.get(status_plain, TestStatus.PENDING)
|
||||||
|
order_idx = name_to_order.get(name_plain, 0)
|
||||||
|
return (_SORT_RANK.get(status, 99), order_idx)
|
||||||
|
|
||||||
|
table.sort(name_col, status_col, key=sort_key)
|
||||||
130
tools/production/app/screens/diag/test_list.py
Normal file
130
tools/production/app/screens/diag/test_list.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""
|
||||||
|
test_list.py — виджет списка тестов с чекбоксами.
|
||||||
|
|
||||||
|
Отображает тесты из list_tests, отмечает HIL-тесты,
|
||||||
|
серит недоступные (HIL без M5).
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
TestListPanel.populate(tests, m5_connected) — заполнить список
|
||||||
|
TestListPanel.get_selected_ids() — список выбранных id
|
||||||
|
TestListPanel.set_enabled(enabled) — блокировать во время прогона
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual import on
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import Button, Checkbox, Label
|
||||||
|
|
||||||
|
|
||||||
|
class TestListPanel(Widget):
|
||||||
|
"""Левая колонка DiagScreen — список тестов с чекбоксами."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
# test_id → Checkbox для быстрого доступа
|
||||||
|
self._checkboxes: dict[str, Checkbox] = {}
|
||||||
|
# test_id → True если HIL-тест недоступен без M5 (постоянное состояние,
|
||||||
|
# не зависящее от прогона). Отдельно от Checkbox.disabled, который
|
||||||
|
# временно перещёлкивается на время прогона тестов через set_enabled().
|
||||||
|
self._hil_unavailable: dict[str, bool] = {}
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("Доступные тесты", classes="section-title")
|
||||||
|
with Horizontal(id="test-list-select-row"):
|
||||||
|
yield Button(
|
||||||
|
"Выбрать все", id="test-list-select-all", classes="-textual-compact"
|
||||||
|
)
|
||||||
|
yield Button(
|
||||||
|
"Снять все", id="test-list-select-none", classes="-textual-compact"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def populate(self, tests: list, m5_connected: bool) -> None:
|
||||||
|
"""
|
||||||
|
Заполнить список тестами. Изначально все тесты НЕ выбраны
|
||||||
|
(сервисник выбирает явно что запускать) — кроме HIL без M5,
|
||||||
|
которые всегда disabled.
|
||||||
|
|
||||||
|
:param tests: list[TestInfo]
|
||||||
|
:param m5_connected: True если M5StampPLC подключён
|
||||||
|
"""
|
||||||
|
# Удалить старые строки (кроме заголовка и select-row)
|
||||||
|
for child in list(self.children):
|
||||||
|
if child.id != "test-list-select-row" and not child.has_class(
|
||||||
|
"section-title"
|
||||||
|
):
|
||||||
|
child.remove()
|
||||||
|
self._checkboxes.clear()
|
||||||
|
self._hil_unavailable.clear()
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for test in tests:
|
||||||
|
hil_unavailable = test.requires_hil and not m5_connected
|
||||||
|
self._hil_unavailable[test.id] = hil_unavailable
|
||||||
|
|
||||||
|
cb = Checkbox(
|
||||||
|
test.name,
|
||||||
|
value=False,
|
||||||
|
disabled=hil_unavailable,
|
||||||
|
id=f"cb-{test.id}",
|
||||||
|
classes="-textual-compact",
|
||||||
|
)
|
||||||
|
self._checkboxes[test.id] = cb
|
||||||
|
|
||||||
|
hil_css = (
|
||||||
|
"test-row-hil-badge hil-disabled"
|
||||||
|
if hil_unavailable
|
||||||
|
else "test-row-hil-badge"
|
||||||
|
)
|
||||||
|
badge_text = "[HIL]" if test.requires_hil else ""
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
Horizontal(
|
||||||
|
cb,
|
||||||
|
Label(badge_text, classes=hil_css),
|
||||||
|
classes="test-row",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
self.mount_all(rows)
|
||||||
|
|
||||||
|
def get_selected_ids(self) -> list[str]:
|
||||||
|
"""Вернуть список id выбранных (checked + not disabled) тестов."""
|
||||||
|
return [
|
||||||
|
tid for tid, cb in self._checkboxes.items() if cb.value and not cb.disabled
|
||||||
|
]
|
||||||
|
|
||||||
|
def set_enabled(self, enabled: bool) -> None:
|
||||||
|
"""
|
||||||
|
Разрешить/запретить изменение чекбоксов во время прогона.
|
||||||
|
|
||||||
|
HIL-чекбоксы без M5 остаются disabled всегда — их постоянное
|
||||||
|
состояние хранится в self._hil_unavailable и не зависит от прогона.
|
||||||
|
"""
|
||||||
|
for tid, cb in self._checkboxes.items():
|
||||||
|
if self._hil_unavailable.get(tid, False):
|
||||||
|
cb.disabled = True # HIL без M5 — всегда недоступен
|
||||||
|
else:
|
||||||
|
cb.disabled = not enabled
|
||||||
|
|
||||||
|
self.query_one("#test-list-select-all", Button).disabled = not enabled
|
||||||
|
self.query_one("#test-list-select-none", Button).disabled = not enabled
|
||||||
|
|
||||||
|
# ── Обработчики ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#test-list-select-all")
|
||||||
|
def _on_select_all(self) -> None:
|
||||||
|
for tid, cb in self._checkboxes.items():
|
||||||
|
if not self._hil_unavailable.get(tid, False):
|
||||||
|
cb.value = True
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#test-list-select-none")
|
||||||
|
def _on_select_none(self) -> None:
|
||||||
|
for tid, cb in self._checkboxes.items():
|
||||||
|
if not self._hil_unavailable.get(tid, False):
|
||||||
|
cb.value = False
|
||||||
298
tools/production/app/screens/flash.py
Normal file
298
tools/production/app/screens/flash.py
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
"""
|
||||||
|
flash.py — экран прошивки (режим A).
|
||||||
|
|
||||||
|
Активен когда обнаружен BootROM SDP (1FC9:0130).
|
||||||
|
Поддерживает три варианта прошивки и chip erase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.css.query import NoMatches
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
Log,
|
||||||
|
ProgressBar,
|
||||||
|
RadioButton,
|
||||||
|
RadioSet,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..flasher import CUSTOM_BINARIES_DIR, Flasher
|
||||||
|
from ..models import FcbVariant, FlashPreset, FlashProgress, FlashTarget
|
||||||
|
from ..widgets import AppFrame
|
||||||
|
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
|
"""
|
||||||
|
Экран прошивки.
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
FlashDone(success, target) — прошивка завершена
|
||||||
|
|
||||||
|
Мониторинг соединения: пока плата не прошивается (self._flashing
|
||||||
|
== False), каждые 1.5с проверяется наличие BootROM SDP на шине.
|
||||||
|
Если плата физически отключена в простое — сессия считается
|
||||||
|
недостоверной, экран сразу уходит на WaitingScreen (см. замечание
|
||||||
|
№4 отчёта). Во время самой прошивки/erase мониторинг приостановлен —
|
||||||
|
обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "go_back", "Назад"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class FlashDone(Message):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
success: bool,
|
||||||
|
target: Optional[FlashTarget] = None,
|
||||||
|
preset: Optional[FlashPreset] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.success = success
|
||||||
|
self.target = target
|
||||||
|
self.preset = preset
|
||||||
|
|
||||||
|
def __init__(self, preset: Optional[FlashPreset] = None, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._flasher = Flasher()
|
||||||
|
self._flashing = False
|
||||||
|
self._preset = preset or FlashPreset()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with AppFrame(id="flash-frame"):
|
||||||
|
yield Label(
|
||||||
|
"⚡ Загрузка прошивки на плату индикатора (режим BootROM)",
|
||||||
|
id="flash-title",
|
||||||
|
)
|
||||||
|
|
||||||
|
with Vertical(id="flash-target-group"):
|
||||||
|
yield Label("Выбор загружаемой прошивки", classes="section-title")
|
||||||
|
with RadioSet(id="flash-radio"):
|
||||||
|
yield RadioButton(
|
||||||
|
"Диагностическая прошивка (firmware_test)",
|
||||||
|
id="radio-fw-test",
|
||||||
|
value=self._preset.target == FlashTarget.FIRMWARE_TEST,
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Серийная прошивка (bootloader + tft_app)",
|
||||||
|
id="radio-production",
|
||||||
|
value=self._preset.target == FlashTarget.PRODUCTION,
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Другое",
|
||||||
|
id="radio-custom",
|
||||||
|
value=self._preset.target == FlashTarget.CUSTOM,
|
||||||
|
)
|
||||||
|
is_custom = self._preset.target == FlashTarget.CUSTOM
|
||||||
|
with Vertical(
|
||||||
|
id="flash-custom-group",
|
||||||
|
classes="" if is_custom else "hidden",
|
||||||
|
):
|
||||||
|
yield Label("Файл (custom_binaries/)", classes="section-title")
|
||||||
|
yield Select[str](
|
||||||
|
[], id="flash-custom-select", prompt="Выберите файл..."
|
||||||
|
)
|
||||||
|
yield Label("Память платы", classes="section-title")
|
||||||
|
yield Select[str](
|
||||||
|
[(v.display_name, v.value) for v in FcbVariant],
|
||||||
|
id="flash-fcb-select",
|
||||||
|
value=self._preset.fcb_variant.value,
|
||||||
|
allow_blank=False,
|
||||||
|
)
|
||||||
|
with Horizontal(id="flash-dcd-row"):
|
||||||
|
yield Switch(value=self._preset.use_dcd, id="flash-dcd-switch")
|
||||||
|
yield Label("Использует SDRAM (DCD)", classes="section-title")
|
||||||
|
|
||||||
|
with Horizontal(id="flash-btn-row"):
|
||||||
|
yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning")
|
||||||
|
yield Button("⚠ Очистить память", id="flash-btn-erase", variant="error")
|
||||||
|
yield Button(
|
||||||
|
"✕ Выйти из приложения", id="flash-btn-quit", variant="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield ProgressBar(
|
||||||
|
id="flash-progress-bar",
|
||||||
|
show_eta=False,
|
||||||
|
show_percentage=False,
|
||||||
|
classes="hidden",
|
||||||
|
)
|
||||||
|
yield Log(id="flash-log", auto_scroll=True)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._start_connection_watch(self._check_sdp_present)
|
||||||
|
self._populate_custom_select()
|
||||||
|
|
||||||
|
def _populate_custom_select(self) -> None:
|
||||||
|
select = self.query_one("#flash-custom-select", Select)
|
||||||
|
names = [p.name for p in Flasher.list_custom_binaries()]
|
||||||
|
select.set_options([(name, name) for name in names])
|
||||||
|
if not names:
|
||||||
|
self._log(f"⚠ Пусто: {CUSTOM_BINARIES_DIR}")
|
||||||
|
return
|
||||||
|
if self._preset.custom_bin_name in names:
|
||||||
|
select.value = self._preset.custom_bin_name
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._stop_connection_watch()
|
||||||
|
|
||||||
|
def _check_sdp_present(self) -> bool:
|
||||||
|
# Не считаем потерей соединения, если идёт активная операция —
|
||||||
|
# flash_usb.py сам обработает реальный обрыв через subprocess.
|
||||||
|
if self._flashing:
|
||||||
|
return True
|
||||||
|
return Flasher.detect_sdp()
|
||||||
|
|
||||||
|
@on(ConnectionLost)
|
||||||
|
def _on_connection_lost(self) -> None:
|
||||||
|
self.post_message(self.FlashDone(success=False, target=None))
|
||||||
|
|
||||||
|
# ── Обработчики ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(RadioSet.Changed, "#flash-radio")
|
||||||
|
def _on_radio_changed(self, event: RadioSet.Changed) -> None:
|
||||||
|
is_custom = event.pressed.id == "radio-custom"
|
||||||
|
group = self.query_one("#flash-custom-group")
|
||||||
|
if is_custom:
|
||||||
|
group.remove_class("hidden")
|
||||||
|
else:
|
||||||
|
group.add_class("hidden")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#flash-btn-flash")
|
||||||
|
def _on_flash_pressed(self) -> None:
|
||||||
|
if self._flashing:
|
||||||
|
return
|
||||||
|
target, bin_path = self._resolve_target()
|
||||||
|
if target is None:
|
||||||
|
self._log("⚠ Выберите файл в custom_binaries/")
|
||||||
|
return
|
||||||
|
|
||||||
|
if target == FlashTarget.CUSTOM:
|
||||||
|
preset = FlashPreset(
|
||||||
|
target=target,
|
||||||
|
custom_bin_name=bin_path.name,
|
||||||
|
use_dcd=self._current_use_dcd(),
|
||||||
|
fcb_variant=self._current_fcb_variant(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
preset = FlashPreset(target=target)
|
||||||
|
|
||||||
|
self._do_flash(target, bin_path, preset)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#flash-btn-erase")
|
||||||
|
def _on_erase_pressed(self) -> None:
|
||||||
|
if not self._flashing:
|
||||||
|
self._do_erase()
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#flash-btn-quit")
|
||||||
|
def _on_quit_pressed(self) -> None:
|
||||||
|
if not self._flashing:
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
def action_go_back(self) -> None:
|
||||||
|
if not self._flashing:
|
||||||
|
self.post_message(self.FlashDone(success=False))
|
||||||
|
|
||||||
|
# ── Workers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=False)
|
||||||
|
async def _do_flash(
|
||||||
|
self, target: FlashTarget, bin_path: Optional[Path], preset: FlashPreset
|
||||||
|
) -> None:
|
||||||
|
self._set_busy(True)
|
||||||
|
self._show_progress(True)
|
||||||
|
self._log(f"▶ Прошивка: {target.value}")
|
||||||
|
ok = await self._flasher.flash(
|
||||||
|
target=target,
|
||||||
|
bin_path=bin_path,
|
||||||
|
use_dcd=preset.use_dcd,
|
||||||
|
fcb_variant=preset.fcb_variant,
|
||||||
|
progress_cb=self._on_progress,
|
||||||
|
)
|
||||||
|
self._set_busy(False)
|
||||||
|
self._finish_progress(ok)
|
||||||
|
self._log("✅ Готово" if ok else "❌ Ошибка")
|
||||||
|
self.post_message(self.FlashDone(success=ok, target=target, preset=preset))
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=False)
|
||||||
|
async def _do_erase(self) -> None:
|
||||||
|
self._set_busy(True)
|
||||||
|
self._show_progress(True)
|
||||||
|
self._log("⚠ Очистка памяти (~30 с)...")
|
||||||
|
ok = await self._flasher.erase_chip(progress_cb=self._on_progress)
|
||||||
|
self._set_busy(False)
|
||||||
|
self._finish_progress(ok)
|
||||||
|
self._log("✅ Очистка памяти завершена" if ok else "❌ Очистка памяти: ошибка")
|
||||||
|
|
||||||
|
# ── Вспомогательные ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve_target(self) -> tuple[Optional[FlashTarget], Optional[Path]]:
|
||||||
|
radio = self.query_one("#flash-radio", RadioSet)
|
||||||
|
pressed_id = radio.pressed_button.id if radio.pressed_button else None
|
||||||
|
|
||||||
|
if pressed_id == "radio-fw-test":
|
||||||
|
return FlashTarget.FIRMWARE_TEST, None
|
||||||
|
if pressed_id == "radio-production":
|
||||||
|
return FlashTarget.PRODUCTION, None
|
||||||
|
if pressed_id == "radio-custom":
|
||||||
|
name = self.query_one("#flash-custom-select", Select).value
|
||||||
|
if name is None or name is Select.BLANK:
|
||||||
|
return None, None
|
||||||
|
p = CUSTOM_BINARIES_DIR / str(name)
|
||||||
|
if not p.exists():
|
||||||
|
self._log(f"⚠ Файл не найден: {p}")
|
||||||
|
return None, None
|
||||||
|
return FlashTarget.CUSTOM, p
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _current_fcb_variant(self) -> FcbVariant:
|
||||||
|
return FcbVariant(self.query_one("#flash-fcb-select", Select).value)
|
||||||
|
|
||||||
|
def _current_use_dcd(self) -> bool:
|
||||||
|
return self.query_one("#flash-dcd-switch", Switch).value
|
||||||
|
|
||||||
|
async def _on_progress(self, progress: FlashProgress) -> None:
|
||||||
|
bar = self.query_one("#flash-progress-bar", ProgressBar)
|
||||||
|
bar.update(total=100, progress=progress.percent)
|
||||||
|
self._log(progress.message)
|
||||||
|
|
||||||
|
def _show_progress(self, visible: bool) -> None:
|
||||||
|
"""Показать/скрыть прогресс-бар. Скрыт в простое — без анимации."""
|
||||||
|
bar = self.query_one("#flash-progress-bar", ProgressBar)
|
||||||
|
if visible:
|
||||||
|
bar.update(total=100, progress=0)
|
||||||
|
bar.remove_class("hidden")
|
||||||
|
else:
|
||||||
|
bar.add_class("hidden")
|
||||||
|
|
||||||
|
def _finish_progress(self, success: bool) -> None:
|
||||||
|
"""Зафиксировать прогресс-бар на 100% с финальным статусом."""
|
||||||
|
bar = self.query_one("#flash-progress-bar", ProgressBar)
|
||||||
|
bar.update(total=100, progress=100)
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
self._flashing = busy
|
||||||
|
self.query_one("#flash-btn-flash", Button).disabled = busy
|
||||||
|
self.query_one("#flash-btn-erase", Button).disabled = busy
|
||||||
|
self.query_one("#flash-btn-quit", Button).disabled = busy
|
||||||
|
|
||||||
|
def _log(self, msg: str) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#flash-log", Log).write_line(msg)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
92
tools/production/app/screens/post_flash.py
Normal file
92
tools/production/app/screens/post_flash.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""
|
||||||
|
post_flash.py — экран-промпт после успешной прошивки firmware_test.
|
||||||
|
|
||||||
|
Показывается только для FlashTarget.FIRMWARE_TEST (диагностическая прошивка) —
|
||||||
|
после неё плата должна быть переведена в нормальный режим (BOOT_MOD → GND)
|
||||||
|
чтобы попасть в DiagScreen. Без этого промпта TUI зацикливался: SDP всё ещё
|
||||||
|
виден → WaitingScreen снова детектит SDP → снова FlashScreen.
|
||||||
|
|
||||||
|
Для PRODUCTION/CUSTOM (либо когда прошивка не удалась) промпт не нужен —
|
||||||
|
сразу WaitingScreen с тем же общим поведением автодетекта.
|
||||||
|
|
||||||
|
Три способа покинуть экран:
|
||||||
|
1. Оператор успел сменить BootMode и нажал "Готово" → WaitingScreen
|
||||||
|
(далее автодетект сам поймает CDC, если плата уже перезагружена)
|
||||||
|
2. Оператор нажал "Выйти" → app.exit()
|
||||||
|
3. Таймаут (по умолчанию 40 с) → автоматически WaitingScreen
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Center, Horizontal
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.timer import Timer
|
||||||
|
from textual.widgets import Button, Label, Static
|
||||||
|
|
||||||
|
from ..widgets import AppFrame
|
||||||
|
|
||||||
|
_AUTO_TIMEOUT_S = 40
|
||||||
|
_TICK_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class PostFlashScreen(Screen):
|
||||||
|
"""
|
||||||
|
Промпт смены BootMode после прошивки firmware_test.
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
Done() — оператор подтвердил или истёк таймаут → пора в WaitingScreen
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Done(Message):
|
||||||
|
"""Готово к переходу на WaitingScreen."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._remaining: int = _AUTO_TIMEOUT_S
|
||||||
|
self._timer: Timer | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with AppFrame(id="post-flash-frame"):
|
||||||
|
with Center(id="post-flash-title-row"):
|
||||||
|
yield Label("✅ firmware_test успешно записан", id="post-flash-title")
|
||||||
|
|
||||||
|
with Center(id="post-flash-instruction-row"):
|
||||||
|
yield Static(
|
||||||
|
"Переведите плату в нормальный режим:\nBOOT_MOD_1 → GND → Reset",
|
||||||
|
id="post-flash-instruction",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield Static("", id="post-flash-countdown")
|
||||||
|
|
||||||
|
with Horizontal(id="post-flash-btn-row"):
|
||||||
|
yield Button("✓ Готово", id="post-flash-btn-ok", variant="success")
|
||||||
|
yield Button(
|
||||||
|
"✕ Выйти из приложения", id="post-flash-btn-quit", variant="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._update_countdown()
|
||||||
|
self._timer = self.set_interval(_TICK_S, self._tick)
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
if self._timer is not None:
|
||||||
|
self._timer.stop()
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
self._remaining -= 1
|
||||||
|
self._update_countdown()
|
||||||
|
if self._remaining <= 0:
|
||||||
|
self.post_message(self.Done())
|
||||||
|
|
||||||
|
def _update_countdown(self) -> None:
|
||||||
|
self.query_one("#post-flash-countdown", Static).update(
|
||||||
|
f"Автопереход через: {self._remaining}с"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "post-flash-btn-ok":
|
||||||
|
self.post_message(self.Done())
|
||||||
|
elif event.button.id == "post-flash-btn-quit":
|
||||||
|
self.app.exit()
|
||||||
144
tools/production/app/screens/waiting.py
Normal file
144
tools/production/app/screens/waiting.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""
|
||||||
|
waiting.py — экран ожидания подключения платы.
|
||||||
|
|
||||||
|
Опрашивает USB каждые _DETECT_INTERVAL_S секунд через Flasher.
|
||||||
|
При обнаружении SDP или CDC отправляет DeviceDetected message в App.
|
||||||
|
|
||||||
|
Принимает опциональный disconnect_reason — короткое сообщение о причине
|
||||||
|
возврата на этот экран (например, "Соединение с платой потеряно"),
|
||||||
|
показывается несколько секунд поверх обычной подсказки, затем исчезает
|
||||||
|
само. Нужно, чтобы потеря USB во время прошивки/диагностики (см. отчёт,
|
||||||
|
замечание №4) не выглядела как необъяснимый скачок экрана — сервисник
|
||||||
|
должен понимать, что это осознанное поведение, а не баг.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Center
|
||||||
|
from textual.css.query import NoMatches
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.timer import Timer
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from ..boot_art import LOGO_ART
|
||||||
|
from ..flasher import Flasher
|
||||||
|
from ..models import AppMode
|
||||||
|
from ..widgets import AppFrame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DETECT_INTERVAL_S = 1.5
|
||||||
|
_SPIN_INTERVAL_S = 0.1
|
||||||
|
_REASON_DISPLAY_S = 4.0
|
||||||
|
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_app_version() -> str:
|
||||||
|
"""
|
||||||
|
Прочитать версию service-tui из pyproject.toml.
|
||||||
|
|
||||||
|
Не используем importlib.metadata — проект не устанавливается как пакет
|
||||||
|
(tool.uv.package = false), метаданные могут отсутствовать. Читаем файл
|
||||||
|
напрямую через tomllib (stdlib, requires-python >= 3.11 уже задан).
|
||||||
|
"""
|
||||||
|
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||||
|
try:
|
||||||
|
with pyproject_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return data.get("project", {}).get("version", "0.0.0")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Не удалось прочитать версию из %s: %s", pyproject_path, exc)
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
_APP_VERSION = _read_app_version()
|
||||||
|
|
||||||
|
|
||||||
|
class WaitingScreen(Screen):
|
||||||
|
"""
|
||||||
|
Экран ожидания.
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
DeviceDetected(mode) — плата обнаружена, mode: FLASHING | DIAGNOSING
|
||||||
|
"""
|
||||||
|
|
||||||
|
class DeviceDetected(Message):
|
||||||
|
"""Плата обнаружена."""
|
||||||
|
|
||||||
|
def __init__(self, mode: AppMode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def __init__(self, disconnect_reason: Optional[str] = None, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._disconnect_reason = disconnect_reason
|
||||||
|
self._spinner_idx: int = 0
|
||||||
|
self._detect_timer: Timer | None = None
|
||||||
|
self._spin_timer: Timer | None = None
|
||||||
|
self._reason_timer: Timer | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with AppFrame(id="waiting-frame"):
|
||||||
|
yield Static(f"service_tool v{_APP_VERSION}", id="waiting-version")
|
||||||
|
with Center(id="waiting-logo-row"):
|
||||||
|
yield Static(LOGO_ART, id="waiting-logo-art")
|
||||||
|
yield Static("", id="waiting-reason", classes="hidden")
|
||||||
|
yield Static("Подключите плату индикатора к USB...", id="waiting-hint")
|
||||||
|
yield Static(_SPINNER_FRAMES[0], id="waiting-spinner")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._detect_timer = self.set_interval(_DETECT_INTERVAL_S, self._poll_usb)
|
||||||
|
self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin)
|
||||||
|
if self._disconnect_reason:
|
||||||
|
self._show_reason(self._disconnect_reason)
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._stop_timers()
|
||||||
|
|
||||||
|
# ── Internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _show_reason(self, reason: str) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#waiting-reason", Static).update(f"⚠ {reason}")
|
||||||
|
self.query_one("#waiting-reason").remove_class("hidden")
|
||||||
|
except NoMatches:
|
||||||
|
return
|
||||||
|
self._reason_timer = self.set_timer(_REASON_DISPLAY_S, self._hide_reason)
|
||||||
|
|
||||||
|
def _hide_reason(self) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#waiting-reason").add_class("hidden")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _spin(self) -> None:
|
||||||
|
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
|
||||||
|
try:
|
||||||
|
self.query_one("#waiting-spinner", Static).update(
|
||||||
|
_SPINNER_FRAMES[self._spinner_idx]
|
||||||
|
)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _poll_usb(self) -> None:
|
||||||
|
if Flasher.detect_sdp():
|
||||||
|
self._stop_timers()
|
||||||
|
self.post_message(self.DeviceDetected(AppMode.FLASHING))
|
||||||
|
elif Flasher.detect_cdc():
|
||||||
|
self._stop_timers()
|
||||||
|
self.post_message(self.DeviceDetected(AppMode.DIAGNOSING))
|
||||||
|
|
||||||
|
def _stop_timers(self) -> None:
|
||||||
|
if self._detect_timer:
|
||||||
|
self._detect_timer.stop()
|
||||||
|
if self._spin_timer:
|
||||||
|
self._spin_timer.stop()
|
||||||
|
if self._reason_timer:
|
||||||
|
self._reason_timer.stop()
|
||||||
5
tools/production/app/widgets/__init__.py
Normal file
5
tools/production/app/widgets/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""widgets — общие переиспользуемые виджеты TUI."""
|
||||||
|
|
||||||
|
from .app_frame import AppFrame
|
||||||
|
|
||||||
|
__all__ = ["AppFrame"]
|
||||||
40
tools/production/app/widgets/app_frame.py
Normal file
40
tools/production/app/widgets/app_frame.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""
|
||||||
|
app_frame.py — общий контейнер фиксированного размера для всех экранов.
|
||||||
|
|
||||||
|
Решает две задачи:
|
||||||
|
1. UX: визуальная рамка фиксированного размера (как в ratatui-приложениях,
|
||||||
|
напр. binsider), центрированная в терминале вне зависимости от размера окна.
|
||||||
|
2. Баг Textual 8.x: AssertionError при mouse drag, когда Screen выступает
|
||||||
|
content_widget напрямую (assert isinstance(content_widget.parent, Widget)
|
||||||
|
падает, потому что в этом сценарии родитель не является валидным Widget).
|
||||||
|
AppFrame как промежуточный контейнер между Screen и содержимым устраняет
|
||||||
|
этот сценарий — content_widget при drag теперь всегда AppFrame или его
|
||||||
|
потомок, у которых .parent валиден.
|
||||||
|
|
||||||
|
Использование в экране::
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with AppFrame():
|
||||||
|
yield Static("...")
|
||||||
|
yield Button("...")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.containers import Container
|
||||||
|
|
||||||
|
|
||||||
|
class AppFrame(Container):
|
||||||
|
"""Контейнер фиксированного размера, центрированный в Screen."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
AppFrame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 160;
|
||||||
|
max-height: 50;
|
||||||
|
border: heavy $primary;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
BIN
tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin
Normal file
BIN
tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin
Normal file
BIN
tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
570
tools/production/docs/DEV_ARCH.md
Normal file
570
tools/production/docs/DEV_ARCH.md
Normal file
|
|
@ -0,0 +1,570 @@
|
||||||
|
# service-tui — техническая архитектура
|
||||||
|
|
||||||
|
> Компонент: `tools/production/` — TUI сервисного инженера (диагностика и
|
||||||
|
> прошивка платы MIMXRT1052CVJ5B).
|
||||||
|
> Документ описывает внутреннее устройство: структуру модулей, протокол
|
||||||
|
> взаимодействия с firmware/M5, экранную архитектуру Textual, известные
|
||||||
|
> особенности фреймворка.
|
||||||
|
> Пользовательская документация (экраны, запуск, конфигурация,
|
||||||
|
> рабочие процессы сервисника) — в [README.md](README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Структура проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/production/
|
||||||
|
├── main.py ← точка входа (10 строк)
|
||||||
|
├── pyproject.toml ← зависимости uv
|
||||||
|
├── uv.lock
|
||||||
|
├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
|
||||||
|
│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
|
||||||
|
└── app/
|
||||||
|
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
|
||||||
|
├── app.tcss ← единый файл стилей для всех экранов
|
||||||
|
├── models.py ← все типы данных (dataclass/Enum)
|
||||||
|
├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen
|
||||||
|
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
|
||||||
|
├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
|
||||||
|
├── flasher.py ← subprocess-обёртка над tools/host/flash_usb.py
|
||||||
|
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
|
||||||
|
├── widgets/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
|
||||||
|
└── screens/
|
||||||
|
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen
|
||||||
|
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата
|
||||||
|
├── flash.py ← FlashScreen — прошивка / chip erase
|
||||||
|
├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки
|
||||||
|
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
|
||||||
|
└── diag/
|
||||||
|
├── __init__.py ← DiagScreen — координатор диагностики
|
||||||
|
├── test_list.py ← TestListPanel — чекбоксы тестов, Выбрать/Снять все
|
||||||
|
├── results.py ← ResultsPanel — DataTable результатов
|
||||||
|
└── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Концепция
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph PC["Сервисный ПК"]
|
||||||
|
TUI["service-tui\n(Textual App)"]
|
||||||
|
subgraph app["app/"]
|
||||||
|
FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
|
||||||
|
M5["m5_client.py\nSerial JSON-lines, UTF-8"]
|
||||||
|
FL["flasher.py\nsubprocess + pyusb detect"]
|
||||||
|
OR["orchestrator.py\nconfirm/progress/timeout router"]
|
||||||
|
end
|
||||||
|
TUI --> FC & M5 & FL & OR
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Board["Плата TFT (MIMXRT1052)"]
|
||||||
|
FW["firmware_test\n(USB CDC)"]
|
||||||
|
ROM["BootROM SDP\n(1FC9:0130)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph HIL["HIL стенд (опционально)"]
|
||||||
|
M5HW["M5StampPLC\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 <yaml> -o <out>,
|
||||||
|
│ │ cwd=tools/host/hab/ (обязательно — relative DCDFilePath
|
||||||
|
│ │ резолвится от этой директории, как в build.just)
|
||||||
|
│ └── стриминг stdout nxpimage в progress_cb (не только logger.debug —
|
||||||
|
│ иначе во время сборки лог FlashScreen выглядит «зависшим»)
|
||||||
|
└── flash_usb.py --bin-path <hab_bin> --fcb-path tools/host/dcd/{fcb_variant}_fdcb.bin
|
||||||
|
(временный .yaml и собранный HAB-образ удаляются после прошивки)
|
||||||
|
```
|
||||||
|
|
||||||
|
`dcd/dcd.bin` — один и тот же файл независимо от проекта (SEMC/SDRAM-init не
|
||||||
|
зависит от того, что именно исполняется), поэтому просто константный путь,
|
||||||
|
без вариантов.
|
||||||
|
|
||||||
|
### 8.4 `flash_usb.py` — явная запись FCB вместо auto-config
|
||||||
|
|
||||||
|
```python
|
||||||
|
def write_fcb_explicit(fcb_path: Path) -> None:
|
||||||
|
"""write-memory 0x60000000 <fcb_path> — буквальная запись 512-байтного
|
||||||
|
FCB-блоба (tag 'FCFB'), а не magic option word 0xF000000F.
|
||||||
|
Обязателен для кастомных бинарей — auto-config Flashloader проверен
|
||||||
|
только для W25Q128."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Активируется флагом `--fcb-path` (только вместе с `--bin-path`). Штатный
|
||||||
|
`--firmware`-путь (три сборки из `BUILD_DIR`) не тронут: без `--fcb-path`
|
||||||
|
поведение идентично тому, что было до этой доработки.
|
||||||
|
|
||||||
|
Заодно увеличен таймаут `blhost` для `flash-erase-all` (chip erase) —
|
||||||
|
`-t 200000` вместо дефолтного: W25Q512 стирается заметно дольше W25Q128,
|
||||||
|
дефолтного таймаута `blhost` не хватало. `flash-erase-region` (стирание
|
||||||
|
пары секторов под FCB+HAB при обычной прошивке) не трогали — там масштаб
|
||||||
|
на порядки меньше, дефолта достаточно независимо от чипа.
|
||||||
|
|
||||||
|
### 8.5 UI (`flash.py`)
|
||||||
|
|
||||||
|
При выборе радиокнопки «Другое» появляется `Vertical#flash-custom-group`:
|
||||||
|
`Select` по содержимому `custom_binaries/` (пересканируется в `on_mount()`),
|
||||||
|
`Select` по `FcbVariant`, `Switch` DCD. Выбор любой ДРУГОЙ радиокнопки в том
|
||||||
|
же `RadioSet` автоматически скрывает группу — отдельного «Назад» не
|
||||||
|
потребовалось, это штатное поведение взаимоисключающего `RadioSet`.
|
||||||
|
|
||||||
|
`#flash-target-group` ограничена `max-height: 18` с собственным скроллом —
|
||||||
|
без этого разросшаяся custom-группа (два `Select` + `Switch`) на маленьком
|
||||||
|
терминале выталкивала `#flash-log` почти до нулевой высоты. `#flash-log`
|
||||||
|
дополнительно защищён `min-height: 6` — лог гарантированно виден даже в
|
||||||
|
худшем случае.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Архитектура экранов
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph ServiceApp["ServiceApp (app.py)"]
|
||||||
|
direction LR
|
||||||
|
WS["WaitingScreen"]
|
||||||
|
FS["FlashScreen"]
|
||||||
|
PF["PostFlashScreen"]
|
||||||
|
DS["DiagScreen"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DiagInternals["DiagScreen (screens/diag/)"]
|
||||||
|
TL["TestListPanel\ntest_list.py"]
|
||||||
|
RP["ResultsPanel (DataTable)\nresults.py"]
|
||||||
|
CP["ConfirmPanel\nconfirm_panel.py"]
|
||||||
|
OR["Orchestrator\norchestrator.py"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Clients["Клиенты"]
|
||||||
|
FC["FirmwareClient"]
|
||||||
|
M5["M5Client"]
|
||||||
|
FL["Flasher"]
|
||||||
|
end
|
||||||
|
|
||||||
|
WS -->|"DeviceDetected(FLASHING)"| FS
|
||||||
|
WS -->|"DeviceDetected(DIAGNOSING)"| DS
|
||||||
|
FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF
|
||||||
|
FS -->|"FlashDone(остальное)"| WS
|
||||||
|
PF -->|"Done"| WS
|
||||||
|
DS -->|"DiagDone(reason)"| WS
|
||||||
|
|
||||||
|
FS --> FL
|
||||||
|
DS --> OR
|
||||||
|
DS --> TL
|
||||||
|
DS --> RP
|
||||||
|
DS --> CP
|
||||||
|
CP -->|"Confirmed"| DS
|
||||||
|
OR --> FC
|
||||||
|
OR --> M5
|
||||||
|
WS --> FL
|
||||||
|
|
||||||
|
FS -.->|"ConnectionWatcherMixin"| FL
|
||||||
|
DS -.->|"ConnectionWatcherMixin"| FL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Жизненный цикл диагностической сессии
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant OP as Оператор
|
||||||
|
participant TUI as ServiceApp
|
||||||
|
participant WS as WaitingScreen
|
||||||
|
participant DS as DiagScreen
|
||||||
|
participant FW as firmware_test
|
||||||
|
participant M5 as M5StampPLC
|
||||||
|
|
||||||
|
OP->>TUI: запустить service_tui
|
||||||
|
TUI->>WS: push_screen()
|
||||||
|
WS->>WS: pyusb poll каждые 1.5 с
|
||||||
|
|
||||||
|
OP->>FW: подключить плату USB
|
||||||
|
WS->>TUI: DeviceDetected(DIAGNOSING)
|
||||||
|
TUI->>FW: auto_connect() → ping→pong
|
||||||
|
TUI->>FW: get_version()
|
||||||
|
TUI->>M5: auto_connect() (опционально)
|
||||||
|
TUI->>DS: switch_screen(fw_version=...)
|
||||||
|
|
||||||
|
DS->>FW: list_tests() → TestInfo×N
|
||||||
|
DS->>FW: get_uid()
|
||||||
|
DS->>DS: populate (тесты НЕ выбраны по умолчанию)
|
||||||
|
DS->>DS: ConnectionWatcherMixin: старт мониторинга
|
||||||
|
|
||||||
|
OP->>DS: выбрать тесты / "Выбрать все" → Запустить
|
||||||
|
DS->>FW: run_selected([...])
|
||||||
|
|
||||||
|
loop Для каждого теста
|
||||||
|
FW-->>DS: test_begin
|
||||||
|
DS->>DS: ResultsPanel.set_running()
|
||||||
|
|
||||||
|
opt progress (напр. usd)
|
||||||
|
FW-->>DS: progress {step, status}
|
||||||
|
DS->>DS: обновить прогресс-строку
|
||||||
|
end
|
||||||
|
|
||||||
|
alt HIL confirm (opto / can)
|
||||||
|
FW-->>DS: confirm_request
|
||||||
|
DS->>M5: relay_set() / can_send() / can_recv()
|
||||||
|
DS->>FW: send_confirm(true/false)
|
||||||
|
else Оператор (display / mqs)
|
||||||
|
FW-->>DS: confirm_request
|
||||||
|
DS->>DS: ConfirmPanel.show_operator()
|
||||||
|
OP->>DS: OK / Нет
|
||||||
|
DS->>FW: send_confirm(true/false)
|
||||||
|
else Кнопки
|
||||||
|
FW-->>DS: confirm_request
|
||||||
|
DS->>DS: ConfirmPanel.show_buttons_hint()
|
||||||
|
OP->>FW: физическое нажатие
|
||||||
|
end
|
||||||
|
|
||||||
|
FW-->>DS: test_result
|
||||||
|
DS->>DS: ResultsPanel.set_result() + сортировка FAIL-наверх
|
||||||
|
end
|
||||||
|
|
||||||
|
FW-->>DS: summary
|
||||||
|
DS->>DS: показать итог PASS / FAIL
|
||||||
|
|
||||||
|
alt Нормальное завершение
|
||||||
|
OP->>DS: ESC / Выйти → DiagDone()
|
||||||
|
TUI->>WS: switch_screen()
|
||||||
|
else Потеря USB
|
||||||
|
DS->>DS: ConnectionLost
|
||||||
|
TUI->>FW: disconnect()
|
||||||
|
TUI->>WS: switch_screen(disconnect_reason=...)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Версионирование firmware
|
||||||
|
|
||||||
|
`firmware_test` версионируется через CMake
|
||||||
|
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
|
||||||
|
`configure_file`. Команда протокола `get_version` (по аналогии с `get_uid`)
|
||||||
|
запрашивается один раз при подключении в `ServiceApp._connect_and_diagnose()`
|
||||||
|
и передаётся в `DiagScreen` параметром конструктора — версия не запрашивается
|
||||||
|
повторно внутри самого экрана.
|
||||||
|
|
||||||
|
Версия самого TUI (`service_tool vX.Y.Z` на `WaitingScreen`) читается
|
||||||
|
отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из
|
||||||
|
`pyproject.toml` напрямую через `tomllib` (stdlib). `importlib.metadata`
|
||||||
|
сознательно не используется — проект не ставится как пакет
|
||||||
|
(`tool.uv.package = false`), метаданных может не быть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Логотип (`boot_art.py`)
|
||||||
|
|
||||||
|
`LOGO_ART` — Rich-markup строка (29×21 символов, цвета `#3ca0dc` для синей
|
||||||
|
части логотипа, `white` для тёмной, `grey37` для фоновых точек), полученная
|
||||||
|
одноразовой coverage-based растеризацией `logo.png` (300×300 RGBA) через
|
||||||
|
Pillow: разбор на сетку символов с компенсацией аспекта шрифта терминала
|
||||||
|
(`ASPECT = 0.5`), классификация фона/двух цветовых групп логотипа по каналам,
|
||||||
|
плотность символа на ячейку — по доле непрозрачных пикселей
|
||||||
|
(`.::+*#`/`.::+%@`).
|
||||||
|
|
||||||
|
Сам скрипт растеризации **не сохранён в репозитории** (использовался
|
||||||
|
разово в песочнице, не входит в `pyproject.toml` TUI — новых
|
||||||
|
runtime-зависимостей `boot_art.py` не добавляет). Если логотип компании
|
||||||
|
сменится — скрипт нужно будет написать заново; логика воспроизводима (см.
|
||||||
|
абзац выше).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Известные грабли Textual 8.x
|
||||||
|
|
||||||
|
Зафиксировано на практике — экономит время при будущих доработках:
|
||||||
|
|
||||||
|
- **`Screen.Message` не существует.** Вложенные сообщения экранов
|
||||||
|
наследуются от `textual.message.Message` напрямую, не от несуществующего
|
||||||
|
атрибута `Screen.Message`.
|
||||||
|
- **`self._running` — зарезервированное имя.** `MessagePump` (предок
|
||||||
|
`Screen`) использует это поле для своего внутреннего message loop.
|
||||||
|
Случайное совпадение имени тихо ломает логику без исключения — в
|
||||||
|
`DiagScreen` переименовано в `_tests_running`.
|
||||||
|
- **`row.mount(child)` сразу после `self.mount(row)` бросает `MountError`** —
|
||||||
|
`row` ещё не прикреплён к DOM. Решение: передавать детей в конструктор
|
||||||
|
контейнера (`Horizontal(cb, label, classes=...)`) и монтировать одним
|
||||||
|
`mount_all()`.
|
||||||
|
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня
|
||||||
|
проекта — постоянно расходится при рефакторинге структуры. Решение: один
|
||||||
|
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
|
||||||
|
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает
|
||||||
|
ширину по умолчанию равную длине заголовка — длинный контент обрезается
|
||||||
|
независимо от `height` строки. Нужно использовать `add_column(label,
|
||||||
|
width=N)` по одной колонке.
|
||||||
|
- **`DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж значений
|
||||||
|
ячеек** (для указанных `columns`), не `row_key` и не `(row_key, row_data)`.
|
||||||
|
Сортировка по `test_id` напрямую невозможна без парсинга содержимого ячеек,
|
||||||
|
которые сами полностью контролируем.
|
||||||
|
- **Нет публичного API для изменения высоты уже добавленной строки.**
|
||||||
|
`update_cell()` меняет только содержимое. Если нужно изменить `height`
|
||||||
|
(например, под более длинный текст) — единственный надёжный путь:
|
||||||
|
`remove_row()` + `add_row(..., height=N)`.
|
||||||
|
- **Центровка текста внутри full-width виджета не решается `Center()`.**
|
||||||
|
`Static`/`Label` без `text-align`, растянутый на всю ширину родителя,
|
||||||
|
прижимает текст к левому краю — `Center()` вокруг такого виджета не
|
||||||
|
помогает (центрировать нечего, ребёнок и так 100% ширины). Нужен
|
||||||
|
`text-align: center` в CSS на самом элементе. Обратный случай — виджет с
|
||||||
|
"естественной" (auto) шириной — центрируется именно через `Center()`.
|
||||||
|
Важно не путать эти два случая (`#waiting-version` — первый случай,
|
||||||
|
`#post-flash-title`/`#post-flash-instruction` — второй).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные открытые вопросы
|
||||||
|
|
||||||
|
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
|
||||||
|
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
|
||||||
|
форсирует Debug через `FIRMWARE_BUILD_TYPE`.
|
||||||
|
- **`tools/shared/m5_agent.py`** — сознательно не делался: pytest
|
||||||
|
HIL-окружение и TUI используют независимые M5-клиенты, признано правильным
|
||||||
|
архитектурным решением, а не техдолгом.
|
||||||
|
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
|
||||||
|
«копирование UID с экрана» — отложены, не начаты.
|
||||||
|
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту
|
||||||
|
детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем
|
||||||
|
понадобится полный батч-режим — потребуется отдельный предохранитель
|
||||||
|
(задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя
|
||||||
|
идентифицировать по UID.
|
||||||
|
- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили
|
||||||
|
не полагаться на него вообще, для кастомных бинарей FCB всегда пишется
|
||||||
|
явно (`--fcb-path`, см. §8.4). Остаётся не до конца понятым, работает ли
|
||||||
|
`configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос
|
||||||
|
снят с повестки архитектурным решением, а не исследован до конца.
|
||||||
|
|
||||||
488
tools/production/docs/INITIAL_PLAN.md
Normal file
488
tools/production/docs/INITIAL_PLAN.md
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
# firmware_test — План разработки
|
||||||
|
|
||||||
|
> Версия: 0.8 | Обновлён после завершения Этапа 6г (bsp_mqs + test_mqs, hardware-verified).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контекст проекта
|
||||||
|
|
||||||
|
**Цель прошивки:** диагностика платы MIMXRT1052CVJ5B на сервисе (возврат по рекламации).
|
||||||
|
Запускается через BootROM (USB SDP), без предварительной прошивки загрузчика.
|
||||||
|
|
||||||
|
**Стенд:**
|
||||||
|
|
||||||
|
- Хост подключается через USB CDC ACM — единственный канал firmware_test
|
||||||
|
- HIL-тесты управляются через M5StampPLC (опционально)
|
||||||
|
- TUI-приложение оркестрирует и firmware_test (CDC), и M5 (Serial) одновременно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
| Компонент | Статус | Примечание |
|
||||||
|
| ------------------------------ | ------ | ------------------------------------------------ |
|
||||||
|
| `bsp_usb_cdc` | ✅ | HIL тест пройден |
|
||||||
|
| firmware_test скелет | ✅ | `main.c` + `cli.c` |
|
||||||
|
| Протокол v2 + test_runner | ✅ | JSON-lines event-driven |
|
||||||
|
| `bsp_sdram` + `test_sdram` | ✅ | 4 фазы: addr/data/seq/retention |
|
||||||
|
| `bsp_qspi_flash` + `test_qspi` | ✅ | JEDEC + erase + rw + addr range |
|
||||||
|
| `bsp_sd` + `test_usd` | ✅ | bsp_sd + FatFS, pre_confirm, 4 шага |
|
||||||
|
| `bsp_display` + `test_display` | ✅ | 4 цвета + ротация, hardware-verified |
|
||||||
|
| `bsp_button` + `test_buttons` | ✅ | 2 кнопки, physical detect, hardware-verified |
|
||||||
|
| Протокол: `list_tests` | ✅ | Этап 6а, hardware-verified |
|
||||||
|
| Протокол: `run_selected` | ✅ | Этап 6а, hardware-verified |
|
||||||
|
| `test_opto` | ✅ | Этап 6б, hardware-verified |
|
||||||
|
| `test_can` | ✅ | Этап 6в, hardware-verified |
|
||||||
|
| HIL pytest firmware_cdc | ✅ | Этап 6д, `FirmwareCdc` + `firmware_cdc` фикстура |
|
||||||
|
| HIL pytest firmware_opto | ✅ | Этап 6е, `06_test_firmware_opto.py` |
|
||||||
|
| HIL pytest firmware_can | ✅ | Этап 6ж, `06_test_firmware_can.py` |
|
||||||
|
| `bsp_mqs` + `test_mqs` | ✅ | Этап 6г, hardware-verified |
|
||||||
|
| Provisioning | ⬜ | Этап 7 |
|
||||||
|
| TUI сервисного инженера | ⬜ | Этап 8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Матрица тестов — итоговая
|
||||||
|
|
||||||
|
| ID | Название | Critical | HIL | Тип | BSP | Статус |
|
||||||
|
| --------- | ------------------ | -------- | --- | ----------- | ------------------ | ------ |
|
||||||
|
| `sdram` | SDRAM 32 MB | ✅ | ❌ | self | `bsp_sdram` ✅ | ✅ |
|
||||||
|
| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | self | `bsp_qspi_flash` ✅ | ✅ |
|
||||||
|
| `usd` | microSD (SDIO) | ❌ | ❌ | interactive | `bsp_sd` ✅ | ✅ |
|
||||||
|
| `display` | TFT Display RGB888 | ❌ | ❌ | interactive | `bsp_display` ✅ | ✅ |
|
||||||
|
| `buttons` | Test Buttons 1/2 | ❌ | ❌ | interactive | `bsp_button` ✅ | ✅ |
|
||||||
|
| `mqs` | MQS Audio Out | ❌ | ❌ | interactive | `bsp_mqs` ✅ | ✅ |
|
||||||
|
| `can` | CAN loopback | ❌ | ✅ | HIL | `bsp_can` ✅ | ✅ |
|
||||||
|
| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | HIL | `bsp_opto` ✅ | ✅ |
|
||||||
|
|
||||||
|
**Убранные тесты (закрытые решения):**
|
||||||
|
|
||||||
|
- `uart_ttl` — LPUART1 dev-инструмент (MCU-Link VCOM), в сервисе не используется
|
||||||
|
- `uart_iso` — RS_RX физически тот же пин что IN в `test_opto`, избыточно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Закрытые архитектурные решения
|
||||||
|
|
||||||
|
> Не пересматривать без явного запроса.
|
||||||
|
|
||||||
|
### Этапы 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 = "<id> state mismatch: expected ACTIVE got INACTIVE"`
|
||||||
|
- Таймаут: `PROTOCOL_CONFIRM_TIMEOUT_MS` (30 с) на каждый шаг
|
||||||
|
|
||||||
|
### 6в — test_can.c ✅
|
||||||
|
|
||||||
|
**Файл:** `firmware/test/src/tests/test_can.c`
|
||||||
|
|
||||||
|
2 шага, оба направления независимо:
|
||||||
|
|
||||||
|
**Шаг 1 — RX (M5 → таргет):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
confirm_request("can_rx_ready")
|
||||||
|
→ TUI: M5.can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF])
|
||||||
|
→ TUI: confirm(true)
|
||||||
|
→ таргет: bsp_can_receive(&frame, 500 мс)
|
||||||
|
→ верификация: frame.id==0x100, frame.data==[0xDE,0xAD,0xBE,0xEF]
|
||||||
|
→ FAIL если timeout или несовпадение
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 2 — TX (таргет → M5):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс)
|
||||||
|
confirm_request("can_tx_verify")
|
||||||
|
→ TUI: M5.can_recv(timeout=500 мс) → верификация id+data
|
||||||
|
→ TUI: confirm(true) если M5 принял корректно, confirm(false) если нет
|
||||||
|
→ FAIL если confirmed=false или timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
- `disableSelfReception=true` — таргет не слышит свой TX, только M5 верифицирует
|
||||||
|
- Init: `bsp_can_init(&cfg)` + `bsp_can_accept_all()`
|
||||||
|
|
||||||
|
### 6г — bsp_mqs + test_mqs.c ✅
|
||||||
|
|
||||||
|
**Файлы:** `bsp/mqs/` + `firmware/test/src/tests/test_mqs.c`
|
||||||
|
|
||||||
|
**bsp_mqs:**
|
||||||
|
|
||||||
|
- SAI3 + eDMA (DMA0 канал 0) + MQS периферия
|
||||||
|
- Стерео PCM16 буфер (L+R идентичны), один физический выход `MQS_RIGHT`
|
||||||
|
- Усилитель LM4875M управляется PWM4 SM0 через RC-фильтр и буферный ОУ LM358
|
||||||
|
- API: `bsp_mqs_init/deinit`, `bsp_mqs_play/play_blocking`, `bsp_mqs_stop`,
|
||||||
|
`bsp_mqs_is_busy`, `bsp_mqs_amp_init/deinit`, `bsp_mqs_amp_set_volume`
|
||||||
|
|
||||||
|
**test_mqs:**
|
||||||
|
|
||||||
|
- Мелодия ~4 с: A4 (440 Гц) + E5 (659 Гц), по 2 с каждая, целочисленная LUT-синусоида
|
||||||
|
- Воспроизведение через `bsp_mqs_play()` (async) с `bsp_usb_cdc_poll()` в цикле
|
||||||
|
- `confirm_request("mqs_tone", "Do you hear a tone?", 15000)` → PASS/FAIL
|
||||||
|
- Порядок init: amp → delay 300 мс → mqs → build_melody (однократно, флаг)
|
||||||
|
- `critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`
|
||||||
|
|
||||||
|
### 6д — HIL pytest для firmware_test ✅
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/hil/conftest.py ← фикстура firmware_cdc
|
||||||
|
tools/hil/06_test_firmware_opto.py
|
||||||
|
tools/hil/06_test_firmware_can.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Фикстура `firmware_cdc`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def firmware_cdc(m5):
|
||||||
|
"""
|
||||||
|
Открывает USB CDC порт firmware_test.
|
||||||
|
firmware_test уже прошит в Flash (не загружается pyOCD).
|
||||||
|
Проверяет живость через ping → pong.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**`FirmwareCdcClient`** — тонкий клиент:
|
||||||
|
|
||||||
|
- `send_cmd(cmd_dict)` — отправить JSON команду
|
||||||
|
- `wait_event(type, timeout_s)` — ждать события нужного типа
|
||||||
|
- `confirm(id, ok)` — отправить `{"type":"confirm","id":"...","confirmed":true/false}`
|
||||||
|
- `run_test(id)` — запустить тест, вернуть test_result dict
|
||||||
|
|
||||||
|
**Justfile:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hil-firmware-opto → pytest 06_test_firmware_opto.py -v
|
||||||
|
hil-firmware-can → pytest 06_test_firmware_can.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7 — Provisioning
|
||||||
|
|
||||||
|
### Что нужно
|
||||||
|
|
||||||
|
1. Читать `OCOTP_UNIQUE_ID` через SDK `fsl_ocotp`
|
||||||
|
2. Отправить `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary`
|
||||||
|
3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста
|
||||||
|
4. Записывать статус в Flash (первый сектор после прошивки, вне XIP)
|
||||||
|
|
||||||
|
### BSP (предварительно)
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* bsp/provisioning/include/bsp/provisioning.h */
|
||||||
|
bsp_status_t bsp_prov_read_uid(uint8_t *p_uid, size_t len); /* 8 байт из OCOTP */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Открытые вопросы — Этап 7
|
||||||
|
|
||||||
|
- [ ] Что именно записывать как «пройдено»: флаг в Flash или только отправить UID?
|
||||||
|
- [ ] Нужна ли защита от повторного provisioning (write-once)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 8 — TUI сервисного инженера
|
||||||
|
|
||||||
|
### Стек технологий
|
||||||
|
|
||||||
|
| Компонент | Выбор | Обоснование |
|
||||||
|
| ------------- | ------------- | ----------------------------------------------------- |
|
||||||
|
| TUI фреймворк | **Textual** | Нативный async, виджеты, SSH-совместим, uv-экосистема |
|
||||||
|
| Serial | pyserial | Уже в стеке (tools/hil) |
|
||||||
|
| Прошивка | spsdk | sdphost + blhost, уже в tools/host |
|
||||||
|
| Конфигурация | python-dotenv | .env файл, совместим с существующим подходом |
|
||||||
|
|
||||||
|
### Структура приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/production/
|
||||||
|
├── pyproject.toml ← зависимости: textual, pyserial, spsdk, python-dotenv
|
||||||
|
├── uv.lock
|
||||||
|
├── main.py ← точка входа
|
||||||
|
├── app/
|
||||||
|
│ ├── tui.py ← Textual App, экраны, layout
|
||||||
|
│ ├── firmware_client.py ← USB CDC asyncio клиент firmware_test
|
||||||
|
│ ├── m5_client.py ← M5 Serial клиент (импортирует tools/shared/m5_agent.py)
|
||||||
|
│ ├── flasher.py ← USB SDP обёртка над spsdk
|
||||||
|
│ ├── orchestrator.py ← confirm_request → M5 action → confirm response
|
||||||
|
│ └── models.py ← TestInfo, TestResult, SessionState (dataclasses)
|
||||||
|
└── README.md
|
||||||
|
|
||||||
|
tools/shared/
|
||||||
|
└── m5_agent.py ← общая M5-логика для hil/ и production/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Два режима работы
|
||||||
|
|
||||||
|
**Режим A — Прошивка** (триггер: VID/PID 1FC9:0130 обнаружен — BootROM SDP)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Прошивка платы ─────────────────────────────────┐
|
||||||
|
│ Обнаружен BootROM (SDP режим) │
|
||||||
|
│ │
|
||||||
|
│ Что прошить? │
|
||||||
|
│ ◉ firmware_test (диагностика) │
|
||||||
|
│ ○ Production (bootloader + tft_app) │
|
||||||
|
│ │
|
||||||
|
│ Файл: [/path/to/firmware_test_hab.bin ···] │
|
||||||
|
│ │
|
||||||
|
│ [ Прошить ] │
|
||||||
|
│ │
|
||||||
|
│ ████████████░░░░░░ 64% Запись во Flash... │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Режим B — Диагностика** (триггер: session_start получен по CDC)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Диагностика платы fw:0.1.0 ─────────────────────┐
|
||||||
|
│ M5StampPLC: ✓ подключён │ Плата: IMXRT1052 │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ Выбор тестов: │ Результаты: │
|
||||||
|
│ ☑ SDRAM 32 MB │ sdram ✓ PASS │
|
||||||
|
│ ☑ QSPI Flash │ qspi ✓ PASS │
|
||||||
|
│ ☑ microSD │ usd ✗ FAIL │
|
||||||
|
│ ☑ TFT Display │ mount failed: 5 │
|
||||||
|
│ ☑ Кнопки │ display ✓ PASS │
|
||||||
|
│ ☑ MQS Audio │ buttons ✓ PASS │
|
||||||
|
│ ☑ CAN loopback [HIL] │ mqs ✓ PASS │
|
||||||
|
│ ☑ Оптовходы [HIL] │ ... │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ [ Запустить выбранные ] [ Все тесты ] │
|
||||||
|
│ ████████████████░░░░ 80% Тест: display │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ ⚠ Экран залит красным цветом? │
|
||||||
|
│ [ ✓ Да ] [ ✗ Нет ] │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поведение confirm_request в TUI
|
||||||
|
|
||||||
|
| Тип теста | Источник confirm | Действие TUI |
|
||||||
|
| -------------------- | ------------------ | --------------------------------------------- |
|
||||||
|
| standalone (display) | оператор | показать prompt, кнопки OK/FAIL, countdown |
|
||||||
|
| standalone (mqs) | оператор | показать prompt, кнопки OK/FAIL, countdown |
|
||||||
|
| standalone (buttons) | физическое нажатие | показать инструкцию, ждать test_result |
|
||||||
|
| HIL (opto, can) | оркестратор | auto: M5 action → confirm (оператор не видит) |
|
||||||
|
|
||||||
|
HIL confirm полностью автоматический — оператор видит только прогресс, не интерактивный prompt.
|
||||||
|
|
||||||
|
### Конфигурация (.env)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Существующие переменные (tools/hil/.env):
|
||||||
|
HIL_VCOM_PORT=/dev/ttyACM0
|
||||||
|
HIL_M5_PORT=/dev/ttyACM1
|
||||||
|
|
||||||
|
# Новые переменные для production TUI:
|
||||||
|
SERVICE_CDC_PORT=AUTO # AUTO = автодетект по session_start
|
||||||
|
SERVICE_M5_PORT=AUTO # AUTO = автодетект, пусто = без M5
|
||||||
|
FIRMWARE_TEST_BIN=build/Release/firmware_test_hab.bin
|
||||||
|
PRODUCTION_BIN_BOOT=build/Release/bootloader_hab.bin
|
||||||
|
PRODUCTION_BIN_APP=build/Release/tft_app_hab.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just host::service-tui # запустить TUI сервисного инженера
|
||||||
|
just host::service-flash <bin> # прошить без TUI (для автоматизации)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Процесс работы сервисника
|
||||||
|
|
||||||
|
**Диагностика (firmware_test уже в Flash):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. Плата в нормальном режиме (BOOT_MOD_1 → GND)
|
||||||
|
2. Подключить USB к сервисному ПК
|
||||||
|
3. just host::service-tui → TUI обнаружил session_start → Режим B
|
||||||
|
4. Выбрать тесты → Запустить → Смотреть результаты
|
||||||
|
```
|
||||||
|
|
||||||
|
**Перепрошивка (нужна новая версия firmware_test или production):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
1. Перемычка BOOT_MOD_1 → 3V3
|
||||||
|
2. Reset, подключить USB
|
||||||
|
3. TUI обнаружил 1FC9:0130 → Режим A
|
||||||
|
4. Выбрать бинарь → Прошить
|
||||||
|
5. Перемычка BOOT_MOD_1 → GND → Reset → TUI переходит в Режим B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Этап 1 протокол v2 + runner
|
||||||
|
✅ Этап 2 bsp_sdram + test_sdram
|
||||||
|
✅ Этап 3 bsp_qspi_flash + test_qspi
|
||||||
|
✅ Этап 4 bsp_sd + test_usd
|
||||||
|
✅ Этап 5 display + buttons
|
||||||
|
✅ Этап 6а протокол: list_tests + run_selected
|
||||||
|
✅ Этап 6б test_opto.c + hardware верификация
|
||||||
|
✅ Этап 6в test_can.c + hardware верификация
|
||||||
|
✅ Этап 6д HIL pytest: firmware_cdc фикстура (FirmwareCdc + firmware_cdc)
|
||||||
|
✅ Этап 6е HIL pytest: 06_test_firmware_opto.py
|
||||||
|
✅ Этап 6ж HIL pytest: 06_test_firmware_can.py
|
||||||
|
✅ Этап 6г bsp_mqs + test_mqs.c + hardware верификация
|
||||||
|
|
||||||
|
⬜ Этап 7 Provisioning (OCOTP UID + Flash-флаг) ← СЛЕДУЮЩИЙ ШАГ
|
||||||
|
|
||||||
|
⬜ Этап 8а tools/production/ скелет + models + clients
|
||||||
|
⬜ Этап 8б orchestrator + базовый Textual UI (список тестов, запуск, результаты)
|
||||||
|
⬜ Этап 8в Экран прошивки (flasher + SDP автодетект)
|
||||||
|
⬜ Этап 8г Provisioning в TUI
|
||||||
|
⬜ Этап 8д tools/shared/m5_agent.py (рефакторинг общей M5-логики)
|
||||||
|
|
||||||
|
⬜ Этап 9 Параллельно: обновить README + DEV_ARCH.md под финальную архитектуру
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Зависимости между этапами
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 6а (протокол) → ✅ 6б (opto) → ✅ 6в (can) → ✅ 6г (mqs)
|
||||||
|
↓
|
||||||
|
✅ 6д (conftest) → ✅ 6е (opto pytest) → ✅ 6ж (can pytest)
|
||||||
|
↓
|
||||||
|
⬜ 7 (provisioning)
|
||||||
|
↓
|
||||||
|
⬜ 8 (TUI)
|
||||||
|
```
|
||||||
8
tools/production/docs/TUI_PLAN.md
Normal file
8
tools/production/docs/TUI_PLAN.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# TUI Service Tool Plan
|
||||||
|
|
||||||
|
1. Навести порядок с экранами "USB-POLL", "TRANSFER", возможно сделать bootlogo с версией программы и лого компании
|
||||||
|
2. Уменьшить размер активной области до предела и сделать его равным для всех экранов
|
||||||
|
3. Добавить обратный отчет в тесте buttons (таймаут)
|
||||||
|
4. Выгрузка результатов тестов в json файл с привязкой к UID микроконтроллера
|
||||||
|
5. Возможно ли копировать UID с экрана терминала?
|
||||||
|
6. Проверить работоспособность HIL тестов (CAN & Opto)
|
||||||
121
tools/production/docs/TUI_SESSION_1.md
Normal file
121
tools/production/docs/TUI_SESSION_1.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Отчёт: разработка и стабилизация service-tui
|
||||||
|
|
||||||
|
**Период:** один рабочий тред, от architecture-планирования до прод-готовности
|
||||||
|
**Объект:** `tools/production/` — TUI-приложение для сервисного инженера (диагностика и прошивка платы MIMXRT1052CVJ5B)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Архитектура и первичная разработка (Этап 8)
|
||||||
|
|
||||||
|
Построено с нуля на Python + Textual, монорепо `tools/production/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/production/
|
||||||
|
├── main.py — точка входа (10 строк)
|
||||||
|
├── pyproject.toml — textual, pyserial, python-dotenv, pyinstaller, pyusb
|
||||||
|
└── app/
|
||||||
|
├── app.py — ServiceApp, роутинг экранов
|
||||||
|
├── app.tcss — единый файл стилей
|
||||||
|
├── models.py — AppMode, TestStatus, FlashTarget, TestInfo, TestResult, SessionState, ConfirmRequest, FlashProgress
|
||||||
|
├── firmware_client.py — async USB CDC клиент firmware_test
|
||||||
|
├── m5_client.py — async M5StampPLC клиент (HIL)
|
||||||
|
├── flasher.py — subprocess-обёртка над tools/host/flash_usb.py
|
||||||
|
├── orchestrator.py — маршрутизация confirm_request, обработка progress/timeout
|
||||||
|
├── widgets/
|
||||||
|
│ └── app_frame.py — общий адаптивный контейнер для всех экранов
|
||||||
|
└── screens/
|
||||||
|
├── waiting.py — WaitingScreen (USB autodetect)
|
||||||
|
├── flash.py — FlashScreen (прошивка/chip erase)
|
||||||
|
├── post_flash.py — PostFlashScreen (промпт смены BootMode)
|
||||||
|
├── connection_watcher.py — миксин мониторинга обрыва USB
|
||||||
|
└── diag/
|
||||||
|
├── __init__.py — DiagScreen (координатор)
|
||||||
|
├── test_list.py — TestListPanel (чекбоксы тестов)
|
||||||
|
├── results.py — ResultsPanel (DataTable результатов)
|
||||||
|
└── confirm_panel.py — ConfirmPanel (prompt + countdown)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые архитектурные решения
|
||||||
|
- **Оркестрация confirm_request** по `id`: HIL (opto/CAN) → автоматически через M5, `btn*` → инструкция без JSON-ответа, остальное → оператор с countdown
|
||||||
|
- **Flasher** не дублирует spsdk-окружение — вызывает `tools/host/flash_usb.py` как subprocess
|
||||||
|
- **Standalone-сборка** через PyInstaller для сервисников без Python
|
||||||
|
- **Три режима прошивки**: firmware_test / Production / кастомный бинарь + chip erase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Расширения протокола и инструментария
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
| ------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
||||||
|
| `--bin-path`, `--erase-chip` в `flash_usb.py` | `tools/host/flash_usb.py` |
|
||||||
|
| `get_version` команда + CMake-версионирование (`version.h.in`) | firmware (`protocol.c/h`, `cli.c`, `CMakeLists.txt`) |
|
||||||
|
| `FIRMWARE_BUILD_TYPE` из `.env` (Debug по умолчанию — Release пока нестабилен) | `flasher.py` |
|
||||||
|
| Поддержка кириллицы в названиях тестов/промптах | `firmware_client.py`, `m5_client.py` (UTF-8 вместо ASCII) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Найденные и исправленные баги (хронологически)
|
||||||
|
|
||||||
|
### Линкер и сборка
|
||||||
|
- **`${PROJECT_SOURCE_DIR}` → `${CMAKE_SOURCE_DIR}`** в `firmware/test/CMakeLists.txt` — после добавления `project(VERSION)` путь к линкер-скрипту стал резолвиться неверно.
|
||||||
|
|
||||||
|
### USB-детект
|
||||||
|
- На macOS BootROM SDP (`1FC9:0130`) не создаёт serial-порт → невидим через `pyserial.list_ports`. Добавлен **pyusb** как primary метод детекта с fallback на `list_ports`.
|
||||||
|
|
||||||
|
### Textual-специфичные баги
|
||||||
|
- `Screen.Message` не существует в Textual 8.x → заменено на `from textual.message import Message`.
|
||||||
|
- `MountError` в `populate()` — `row.mount(child)` до прикрепления `row` к DOM → исправлено передачей детей в конструктор + `mount_all()`.
|
||||||
|
- `CSS_PATH` на каждом экране резолвился относительно файла класса и постоянно расходился с реальным расположением `.tcss` → **унифицировано**: один `app.tcss` с `CSS_PATH` только на `ServiceApp`.
|
||||||
|
- **Краш при drag мыши** (`assert isinstance(content_widget.parent, Widget)`) — `Screen` выступал `content_widget` напрямую → решено введением общего `AppFrame`-контейнера между `Screen` и содержимым.
|
||||||
|
- **`self._running`** в `DiagScreen` случайно совпало с приватным полем `MessagePump._running` из самого Textual → кнопка "Выйти" была перманентно заблокирована. Переименовано в `_tests_running`.
|
||||||
|
- `DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж **значений ячеек**, не `row_key` — пришлось сортировать по содержимому ячейки "Статус", которое сами полностью контролируем.
|
||||||
|
- `table.add_columns()` (множественное число) не принимает `width=` → колонка "Детали" обрезалась по длине заголовка. Исправлено через `add_column()` по одной с явной шириной.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- `AppFrame` с жёстким `width: 140; height: 44` обрезал контент на терминалах меньшего размера → сделан **адаптивным** (`width/height: 100%` с потолком `max-width: 160; max-height: 50`).
|
||||||
|
- `#diag-frame` на `layout: grid` с ручным расчётом `grid-rows` рассинхронизировался с реальным числом/высотой children (кнопки с `border: tall` не помещались в выделенную строку) → переход на `layout: vertical` с единственным `1fr` на рабочую зону.
|
||||||
|
- `#results-empty.hidden` не имел CSS-правила `display: none` → пустой контейнер с `height: 1fr` продолжал выталкивать таблицу результатов вниз даже будучи скрытым.
|
||||||
|
|
||||||
|
### Логика приложения
|
||||||
|
- **`TestListPanel.set_enabled()`** путал постоянное состояние "HIL без M5" с временной блокировкой на время прогона → чекбоксы навсегда залипали disabled после первого запуска. Исправлено отдельным словарём `_hil_unavailable`.
|
||||||
|
- **Таймаут чтения порта** (`_recv_until`) тихо завершался без сигнала → зависший тест навсегда оставался в RUNNING, кнопки "Выйти"/запуска блокировались навсегда. Теперь генератор **гарантированно** завершается одним `SUMMARY` (настоящим или синтетическим), зависший тест получает `FAIL` с понятным detail.
|
||||||
|
- **`progress`-событие** протокола (документированное в PROTOCOL.md, используется тестом USD) ошибочно считалось неизвестным/ошибочным → теперь явно обрабатывается как `TEST_PROGRESS`.
|
||||||
|
|
||||||
|
### Firmware (USD-тест)
|
||||||
|
- **USD зависает намертво на втором прогоне.** Причина: non-blocking USDHC host driver SDK оставался в состоянии "ожидание завершения транзакции" после `SD_HostDeinit()`, плюс структура `g_sd` не обнулялась между прогонами. Фикс: `USDHC_Reset(..., kUSDHC_ResetAll, ...)` + `memset(&g_sd, 0, ...)` в `bsp_sd_init()`/`bsp_sd_deinit()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UX-доработки (по согласованному плану итераций)
|
||||||
|
|
||||||
|
| Итерация | Что сделано |
|
||||||
|
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Стабильность | `AppFrame`, скрытие ProgressBar в простое, кнопка "Выйти" на всех экранах |
|
||||||
|
| Workflow | `PostFlashScreen` (промпт смены BootMode после прошивки firmware_test — только для этого сценария), тесты изначально не выбраны + кнопки "Выбрать все"/"Снять все", полный UID в шапке |
|
||||||
|
| Polish | `ResultsPanel` переведён на `DataTable`: сортировка FAIL-наверх (стабильная внутри группы), подсветка FAIL-строки целиком, перенос длинных `detail` на несколько строк без обрезания, empty-state с подсказкой |
|
||||||
|
| Отчёт №2 | Убраны проценты в статус-баре (полоса осталась), мониторинг обрыва USB (`ConnectionWatcherMixin`) на `FlashScreen`/`DiagScreen` с разрывом сессии и понятным баннером причины на `WaitingScreen` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Текущее состояние
|
||||||
|
|
||||||
|
**Готово и протестировано (headless):**
|
||||||
|
- Полный цикл: WaitingScreen → Flash/Diag → результат → возврат
|
||||||
|
- Прошивка (3 варианта) + chip erase + версионирование
|
||||||
|
- Диагностика: список тестов, выборочный/полный запуск, HIL через M5, кириллица
|
||||||
|
- Обработка обрывов: таймаут теста, потеря USB, повторные прогоны
|
||||||
|
- Адаптивная вёрстка на диапазоне терминалов 80×24 → 220×60
|
||||||
|
|
||||||
|
**Известные открытые вопросы / не доделано:**
|
||||||
|
- Release-сборка firmware нестабильна (медленное мигание — подозрение на проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно форсирует Debug через `FIRMWARE_BUILD_TYPE`
|
||||||
|
- `docs/DEV_ARCH.md`, `CHANGELOG.md`, `PLAN.md` — подготовлены диффы для финализации документации, но не применялись по твоему решению ("пока не буду обновлять документацию, нужно всё проверить")
|
||||||
|
- `tools/shared/m5_agent.py` — сознательно не делался: pytest HIL-окружение и TUI используют независимые M5-клиенты, признано правильным архитектурным решением, а не техдолгом
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Рекомендации для следующего треда
|
||||||
|
|
||||||
|
1. Перед стартом — синхронизировать единую копию репозитория со всеми патчами из этого треда (было замечено расхождение версий файлов между чатом и локальной копией один раз, см. эпизод с TUI_REPORT.md).
|
||||||
|
2. Дальнейшее тестирование на реальном железе: полный цикл diagnostics с HIL (M5 подключён), повторные циклы прошивки/chip erase, граничные случаи USB-отключения во время разных операций.
|
||||||
|
3. Когда стабильность подтверждена — вернуться к обновлению `DEV_ARCH.md`/`CHANGELOG.md`/`PLAN.md` под финальную архитектуру.
|
||||||
|
4. Разобрать Release-сборку firmware (сравнить `hab_firmware_test_debug.yaml` vs `hab_firmware_test_release.yaml`).
|
||||||
163
tools/production/docs/TUI_SESSION_2.md
Normal file
163
tools/production/docs/TUI_SESSION_2.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# Отчёт: сессия доработки service-tui (продолжение)
|
||||||
|
|
||||||
|
**Период:** один рабочий тред, продолжение после `TUI_SESSION.md` (архитектура и стабилизация Этапа 8)
|
||||||
|
**Объект:** `tools/production/` — TUI сервисного инженера
|
||||||
|
**Вход в сессию:** `TUI_PLAN.md` (6 пунктов), реальный HIL-стенд (M5StampPLC подключён)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Пункт 6 — проверка HIL (CAN & Opto) ✅ ЗАКРЫТ
|
||||||
|
|
||||||
|
M5StampPLC физически был подключён, но `DiagScreen` показывал "M5 Bench: ✕ нет связи".
|
||||||
|
Три независимых бага в одном файле `tools/production/app/m5_client.py` — рассинхрон
|
||||||
|
между тем, что реально шлёт `tools/hil/m5/agent.py`, и тем, что ожидал клиент TUI.
|
||||||
|
HIL pytest (`06_test_firmware_opto.py`/`06_test_firmware_can.py`) эти баги не ловил,
|
||||||
|
потому что ходит по своему пути — порт берёт напрямую из `.env` (`HIL_M5_PORT`), а не
|
||||||
|
через VID/PID автодетект, и не использует `M5Client` вовсе.
|
||||||
|
|
||||||
|
| # | Баг | Было | Стало | Как нашли |
|
||||||
|
| --- | ------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | PID автодетекта M5 | `_M5_PID = 0x1001` (hardcode) | `_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)` | `just host::m5-scan` показал `303a:4001`, не `303a:1001` — `0x1001` это PID ROM-режима ESP32-S3, `0x4001` — PID рантайма с запущенным MicroPython/агентом |
|
||||||
|
| 2 | Поле успеха ответа агента | `resp.get("status") == "ok"` (везде: `ping`, `relay_set`, `relay_get`, `can_send`, `can_recv`) | `resp.get("ok") is True` | Сверка с `HIL_BENCH.md`/`HIL_HOW_TO.md` — агент отвечает `{"ok": true, ...}`, поля `"status"` в протоколе агента нет вообще |
|
||||||
|
| 3 | Ключ канала реле | `{"cmd": "relay_set", "relay": relay, ...}` | `{"cmd": "relay_set", "ch": relay, ...}` (и `relay_get`) | Подтверждено `grep` по `tools/hil/m5/agent.py` — обработчик читает `cmd["ch"]`, ключа `"relay"` не существует |
|
||||||
|
|
||||||
|
`_M5_VID` заодно вынесен в `os.environ` по аналогии с `SERVICE_CDC_VID/PID` в `app.py`
|
||||||
|
(был раньше hardcode `0x303A`, поведение не поменялось, только стал переопределяемым).
|
||||||
|
|
||||||
|
`can_send`/`can_recv` сверены отдельно (`grep -A 12` по агенту) — багов не найдено,
|
||||||
|
формат ответа (`{"ok": true, "id": ..., "data": [...]}`) уже совпадал с ожиданиями клиента.
|
||||||
|
|
||||||
|
**Подтверждено на живом стенде:**
|
||||||
|
```
|
||||||
|
connected: True
|
||||||
|
ping: True
|
||||||
|
relay_set(3, True): True
|
||||||
|
relay_get(3): True
|
||||||
|
relay_set(3, False): True
|
||||||
|
```
|
||||||
|
Прогон `opto`/`can` через `service-tui` целиком (не только pytest) — пройден, о чём
|
||||||
|
сообщил заказчик по итогу. Пункт 6 закрыт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Пункт 2 — единый размер активной области ✅ ЗАКРЫТ
|
||||||
|
|
||||||
|
**Файл:** `tools/production/app/app.tcss`, блок `AppFrame`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* было */
|
||||||
|
max-width: 160;
|
||||||
|
max-height: 50;
|
||||||
|
|
||||||
|
/* стало */
|
||||||
|
max-width: 112;
|
||||||
|
max-height: 35;
|
||||||
|
```
|
||||||
|
|
||||||
|
Итоговый размер `112×35` подобран и подтверждён визуально пользователем на реальных
|
||||||
|
скриншотах всех пяти экранов (`WAITING`, `WAITING_AFTER_LOSS`, `FLASH`, `POST_FLASH`,
|
||||||
|
`TEST_SCREEN`). Нижняя граница по ширине обоснована жёстко: `TestListPanel(width:38)`
|
||||||
|
+ `ResultsPanel(width:70)` рядом на `DiagScreen` дают 108 + рамка = 110 — меньше сжимать
|
||||||
|
уже нельзя. `width/height: 100%` (адаптивность под маленькие терминалы) не тронуты —
|
||||||
|
менялся только потолок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Пункт 3 — countdown в тесте buttons ❌ РЕШЕНО НЕ ДЕЛАТЬ
|
||||||
|
|
||||||
|
Разобрал возможную реализацию (передать `timeout_ms` в `ConfirmPanel.show_buttons_hint()`,
|
||||||
|
завести отдельный `auto_fail`-флаг в таймере, чтобы не дёргать случайный
|
||||||
|
`Confirmed`-message в ветке buttons, где хост не должен ничего отправлять). Заказчик
|
||||||
|
решил не усложнять рабочую логику ради UX-мелочи — пункт закрыт без изменений в коде.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Пункт 1 — bootlogo / логотип компании / центровка экранов ✅ ЗАКРЫТ
|
||||||
|
|
||||||
|
Изначально запрошенная "чистка лога USB-POLL/TRANSFER" (часть А) — отменена заказчиком,
|
||||||
|
не актуальна. Весь пункт свёлся к части Б: логотип + центровка.
|
||||||
|
|
||||||
|
### 4.1 Логотип — программная растеризация `logo.png`
|
||||||
|
|
||||||
|
Ручная отрисовка ASCII-арта отклонена как ненадёжная — вместо этого написан
|
||||||
|
одноразовый скрипт (Pillow, вне рантайма приложения) с coverage-based растеризацией:
|
||||||
|
|
||||||
|
1. Разбор `logo.png` (300×300 RGBA) на сетку символов с компенсацией соотношения
|
||||||
|
сторон шрифта терминала (`ASPECT = 0.5`).
|
||||||
|
2. Фон определялся не только по альфа-каналу (у файла альфа=0 только по внешним
|
||||||
|
углам), но и по близости цвета к белому (`r,g,b > 235`) — иначе весь непрозрачный
|
||||||
|
белый подложечный слой считался "закрашенным".
|
||||||
|
3. Классификация двух цветовых групп логотипа по каналам (`avgB - avgR > 15` → синий,
|
||||||
|
иначе тёмный/чёрный), калибровка порога — по гистограмме реальных цветов пикселей
|
||||||
|
(`(60,160,220)` синий кластер vs `(0,0,0)` чёрный кластер, чётко разделены).
|
||||||
|
4. Плотность символа на ячейку — по доле непрозрачных пикселей (`.::+*#` для синего,
|
||||||
|
`.::+%@` для тёмного), обрезка до bounding box контента → финальный размер 29×21.
|
||||||
|
|
||||||
|
**Результат вынесен в новый файл** `tools/production/app/boot_art.py` — константа
|
||||||
|
`LOGO_ART` (Rich-markup строка, цвета `#3ca0dc` для синей части, `white` для тёмной,
|
||||||
|
`grey37` для фоновых точек). Никаких новых рантайм-зависимостей (Pillow использовался
|
||||||
|
только в песочнице для генерации, не входит в `pyproject.toml` TUI).
|
||||||
|
|
||||||
|
### 4.2 Версия из `pyproject.toml`
|
||||||
|
|
||||||
|
`tools/production/app/screens/waiting.py` — функция `_read_app_version()`, читает
|
||||||
|
`[project].version` напрямую через `tomllib` (stdlib, `requires-python >= 3.11` уже
|
||||||
|
задан). `importlib.metadata` сознательно не использован — проект не ставится как пакет
|
||||||
|
(`tool.uv.package = false` в `pyproject.toml`), метаданных может не быть.
|
||||||
|
|
||||||
|
### 4.3 Финальная компоновка WaitingScreen
|
||||||
|
|
||||||
|
- Убран старый текст `"TFT Indicator Board\nService Tool"`.
|
||||||
|
- Добавлена строка `"service_tool vX.Y.Z"` — **над** артом (не под), цвет `$warning`
|
||||||
|
(тот же оранжевый, что в заголовке `FlashScreen` — единообразие между экранами).
|
||||||
|
- `#waiting-frame`: `align: center middle` → `align: center top` (блок прижат к верху,
|
||||||
|
не висит по вертикальному центру).
|
||||||
|
- Баг центровки самого текста версии: `Static` без `text-align` растягивается на всю
|
||||||
|
ширину родителя и текст внутри прижимается к левому краю — обёртка `Center()` тут
|
||||||
|
не помогает (центрировать нечего, ребёнок и так 100% ширины). Фикс — `text-align:
|
||||||
|
center` в CSS на самом `#waiting-version`, а не контейнер-обёртка.
|
||||||
|
- Та же причина и тот же фикс повторно всплыли на `PostFlashScreen` для
|
||||||
|
`#post-flash-title` (`Label`) и `#post-flash-instruction` (`Static`, `width: auto`) —
|
||||||
|
здесь, наоборот, у элементов есть "естественная" ширина, поэтому раз уже `Center()`
|
||||||
|
вокруг них — верное решение (в отличие от `#waiting-version`, где элемент full-width).
|
||||||
|
Оба случая — одна и та же путаница между "центрировать full-width текст" (нужен
|
||||||
|
`text-align`) и "центрировать auto-width блок" (нужен `Center()`), просто с
|
||||||
|
противоположными по природе виджетами.
|
||||||
|
|
||||||
|
**Файлы, изменённые в рамках пункта 1:**
|
||||||
|
- `tools/production/app/boot_art.py` — новый, константа `LOGO_ART`
|
||||||
|
- `tools/production/app/screens/waiting.py` — `compose()`, `_read_app_version()`
|
||||||
|
- `tools/production/app/screens/post_flash.py` — `compose()` (обёртки `Center()`)
|
||||||
|
- `tools/production/app/app.tcss` — секции `WaitingScreen`, `PostFlashScreen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Текущее состояние `TUI_PLAN.md`
|
||||||
|
|
||||||
|
| # | Пункт | Статус |
|
||||||
|
| --- | -------------------------------------------- | -------------------------------------------- |
|
||||||
|
| 1 | Экраны USB-POLL/TRANSFER, bootlogo | ✅ закрыт (часть А отменена, часть Б сделана) |
|
||||||
|
| 2 | Единый размер активной области | ✅ закрыт (112×35) |
|
||||||
|
| 3 | Countdown в тесте buttons | ❌ решено не делать |
|
||||||
|
| 4 | Экспорт результатов в JSON с привязкой к UID | ⏸ отложен |
|
||||||
|
| 5 | Копирование UID с экрана терминала | ⏸ отложен |
|
||||||
|
| 6 | Проверка HIL (CAN & Opto) | ✅ закрыт, 3 бага найдены и исправлены |
|
||||||
|
|
||||||
|
Пункты 4 и 5 сохранены в памяти для следующих сессий — не начаты, ждут возврата.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Рекомендации для следующего треда
|
||||||
|
|
||||||
|
1. Перед стартом пунктов 4/5 — обсудить формат JSON-экспорта (структура файла,
|
||||||
|
куда сохраняется, привязка к UID в имени/содержимом) и способ копирования UID
|
||||||
|
(зависит от поддержки OSC 52 в целевом терминале сервисника — стоит уточнить
|
||||||
|
заранее, SSH-сессии могут не поддерживать).
|
||||||
|
2. `m5_client.py` теперь соответствует реальному протоколу `agent.py` — при любых
|
||||||
|
будущих изменениях `agent.py` (новые команды, смена формата ответа) стоит сразу
|
||||||
|
сверяться через `grep` по агенту, а не полагаться на `HIL_BENCH.md`/`HIL_HOW_TO.md`
|
||||||
|
(документация местами не успевала за кодом — минимум один пример уже был найден).
|
||||||
|
3. `LOGO_ART` в `boot_art.py` — статичный артефакт. Если лого компании поменяется,
|
||||||
|
скрипт растеризации не сохранён в репозитории (был одноразовым в песочнице) —
|
||||||
|
при необходимости регенерации нужно будет написать заново (логика описана в
|
||||||
|
разделе 4.1 этого отчёта, воспроизводима).
|
||||||
42
tools/production/main.py
Normal file
42
tools/production/main.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""main.py — точка входа service-tui."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_ENV_FILE = _REPO_ROOT / ".env"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
if _ENV_FILE.exists():
|
||||||
|
load_dotenv(_ENV_FILE)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging() -> None:
|
||||||
|
log_dir = Path(os.environ.get("SERVICE_LOG_DIR", str(Path(__file__).parent)))
|
||||||
|
log_file = log_dir / "service_tui.log"
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||||
|
handlers=[logging.FileHandler(log_file, encoding="utf-8")],
|
||||||
|
)
|
||||||
|
logging.getLogger("textual").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_setup_logging()
|
||||||
|
from app.app import ServiceApp
|
||||||
|
|
||||||
|
ServiceApp().run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main() or 0)
|
||||||
22
tools/production/pyproject.toml
Normal file
22
tools/production/pyproject.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[project]
|
||||||
|
name = "service-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"textual>=0.80.0",
|
||||||
|
"pyserial>=3.5",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"pyinstaller>=6.0.0",
|
||||||
|
"pyusb>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
service-tui = "main:main"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
264
tools/production/uv.lock
Normal file
264
tools/production/uv.lock
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "altgraph"
|
||||||
|
version = "0.17.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkify-it-py"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "uc-micro-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "macholib"
|
||||||
|
version = "1.16.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
linkify = [
|
||||||
|
{ name = "linkify-it-py" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdit-py-plugins"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pefile"
|
||||||
|
version = "2024.8.26"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller"
|
||||||
|
version = "6.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "pyinstaller-hooks-contrib" },
|
||||||
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller-hooks-contrib"
|
||||||
|
version = "2026.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyserial"
|
||||||
|
version = "3.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyusb"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pywin32-ctypes"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "15.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "service-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyinstaller" },
|
||||||
|
{ name = "pyserial" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyusb" },
|
||||||
|
{ name = "textual" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "pyinstaller", specifier = ">=6.0.0" },
|
||||||
|
{ name = "pyserial", specifier = ">=3.5" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "pyusb", specifier = ">=1.0.0" },
|
||||||
|
{ name = "textual", specifier = ">=0.80.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "82.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textual"
|
||||||
|
version = "8.2.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uc-micro-py"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue