# firmware_test: service-tui is ready for packing, docs updated
This commit is contained in:
parent
2a6256e745
commit
e50b2f35aa
15 changed files with 606 additions and 59 deletions
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -142,15 +161,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Диагностика
|
## Диагностика
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ 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 ──────────────────────────────────────────────────────────────────
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -216,10 +218,39 @@ def write_fcb() -> 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) -> None:
|
def flash(hab_bin: Path, ram_only: bool = False, fcb_path: Path | None = None) -> None:
|
||||||
"""Прошить HAB-образ в Flash или загрузить в RAM."""
|
"""Прошить HAB-образ в Flash или загрузить в RAM."""
|
||||||
if not hab_bin.exists():
|
if not hab_bin.exists():
|
||||||
print(f"[ERROR] Файл не найден: {hab_bin}", file=sys.stderr)
|
print(f"[ERROR] Файл не найден: {hab_bin}", file=sys.stderr)
|
||||||
|
|
@ -280,6 +311,9 @@ def flash(hab_bin: Path, ram_only: bool = False) -> None:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if fcb_path is not None:
|
||||||
|
write_fcb_explicit(fcb_path)
|
||||||
|
else:
|
||||||
write_fcb()
|
write_fcb()
|
||||||
|
|
||||||
run(
|
run(
|
||||||
|
|
@ -318,7 +352,7 @@ def erase_chip() -> None:
|
||||||
[
|
[
|
||||||
"blhost",
|
"blhost",
|
||||||
"-t",
|
"-t",
|
||||||
"100000",
|
ERASE_ALL_TIMEOUT_MS,
|
||||||
"-u",
|
"-u",
|
||||||
BLHOST_USB,
|
BLHOST_USB,
|
||||||
"--",
|
"--",
|
||||||
|
|
@ -342,6 +376,17 @@ def main() -> None:
|
||||||
epilog=__doc__,
|
epilog=__doc__,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--fcb-path",
|
||||||
|
type=Path,
|
||||||
|
metavar="PATH",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Явный FCB-блоб (512 байт) для записи в 0x60000000 вместо "
|
||||||
|
"auto-config Flashloader. Имеет смысл только с --bin-path."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Группа: что прошивать (взаимоисключающие варианты)
|
# Группа: что прошивать (взаимоисключающие варианты)
|
||||||
target_group = parser.add_mutually_exclusive_group()
|
target_group = parser.add_mutually_exclusive_group()
|
||||||
target_group.add_argument(
|
target_group.add_argument(
|
||||||
|
|
@ -381,6 +426,9 @@ def main() -> None:
|
||||||
if args.erase_chip and args.ram_only:
|
if args.erase_chip and args.ram_only:
|
||||||
parser.error("--erase-chip несовместим с --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:
|
if args.firmware is None and args.bin_path is None and not args.erase_chip:
|
||||||
parser.error("Укажи --firmware, --bin-path или --erase-chip")
|
parser.error("Укажи --firmware, --bin-path или --erase-chip")
|
||||||
|
|
||||||
|
|
@ -413,7 +461,7 @@ def main() -> None:
|
||||||
if args.erase_chip:
|
if args.erase_chip:
|
||||||
erase_chip()
|
erase_chip()
|
||||||
elif hab_bin is not None:
|
elif hab_bin is not None:
|
||||||
flash(hab_bin, ram_only=args.ram_only)
|
flash(hab_bin, ram_only=args.ram_only, fcb_path=args.fcb_path)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,18 @@ TUI не пытается восстановить прежнее состоян
|
||||||
│ ◉ Диагностическая прошивка (firmware_test) │
|
│ ◉ Диагностическая прошивка (firmware_test) │
|
||||||
│ ○ Серийная прошивка (bootloader + tft_app) │
|
│ ○ Серийная прошивка (bootloader + tft_app) │
|
||||||
│ ○ Другое │
|
│ ○ Другое │
|
||||||
│ [ Имя бинарного файла в custom_binaries/ (.bin) ]│ ← только если «Другое»
|
│ Файл (custom_binaries/) │
|
||||||
|
│ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое»
|
||||||
|
│ Память платы │
|
||||||
|
│ [ W25Q128 / W25Q64 ▾ ] │
|
||||||
|
│ ○ Использует SDRAM (DCD) │
|
||||||
│ │
|
│ │
|
||||||
│ [ ▶ Загрузить ] [ ⚠ Очистить память ] [ ✕ Выйти из приложения ] │
|
│ [ ▶ Загрузить ] [ ⚠ Очистить память ] [ ✕ Выйти из приложения ] │
|
||||||
│ │
|
│ │
|
||||||
│ ████████████░░░░░░ ← без числового % │
|
│ ████████████░░░░░░ ← без числового % │
|
||||||
│ ┌────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────┐ │
|
||||||
│ │ ▶ Прошивка: firmware_test │ │
|
│ │ ▶ Сборка HAB-образа (nxpimage)... │ │
|
||||||
|
│ │ ▶ Прошивка: TFT_BOOTLOADER_NEW │ │
|
||||||
│ │ ... │ │
|
│ │ ... │ │
|
||||||
│ └────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────┘ │
|
||||||
└────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────┘
|
||||||
|
|
@ -59,7 +64,25 @@ TUI не пытается восстановить прежнее состоян
|
||||||
|
|
||||||
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
|
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
|
||||||
во время активной операции (скрыт в простое), без числового `%` — только
|
во время активной операции (скрыт в простое), без числового `%` — только
|
||||||
полоса и построчный лог в реальном времени.
|
полоса и построчный лог в реальном времени. Панель выбора прошивки
|
||||||
|
ограничена по высоте и скроллится сама, если разрастается (варианты
|
||||||
|
"Другое") — лог снизу гарантированно не сжимается меньше 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)
|
### Переход в рабочий режим (PostFlashScreen)
|
||||||
|
|
||||||
|
|
@ -146,11 +169,25 @@ Production/Custom этот шаг не нужен).
|
||||||
5. TUI автоматически попадает в DiagScreen при следующем подключении
|
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
|
### Chip Erase
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3)
|
1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3)
|
||||||
2. FlashScreen → Очистить память (~30 с)
|
2. FlashScreen → Очистить память (~30 с для W25Q128, дольше для W25Q512)
|
||||||
3. После erase BootROM не загрузит прошивку — требуется перепрошить
|
3. После erase BootROM не загрузит прошивку — требуется перепрошить
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -175,6 +212,11 @@ SERVICE_CDC_PID=00ad
|
||||||
SERVICE_M5_VID=303a
|
SERVICE_M5_VID=303a
|
||||||
SERVICE_M5_PID=4001
|
SERVICE_M5_PID=4001
|
||||||
|
|
||||||
|
# Директория с сырыми кастомными бинарниками для FlashScreen → "Другое".
|
||||||
|
# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом
|
||||||
|
# с main.py в dev-режиме); создаётся автоматически при старте.
|
||||||
|
# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries
|
||||||
|
|
||||||
# Тип сборки firmware_test для прошивки (Debug | Release).
|
# Тип сборки firmware_test для прошивки (Debug | Release).
|
||||||
# Release временно нестабилен — по умолчанию Debug.
|
# Release временно нестабилен — по умолчанию Debug.
|
||||||
FIRMWARE_BUILD_TYPE=Debug
|
FIRMWARE_BUILD_TYPE=Debug
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from textual.binding import Binding
|
||||||
|
|
||||||
from .firmware_client import FirmwareClient
|
from .firmware_client import FirmwareClient
|
||||||
from .m5_client import M5Client
|
from .m5_client import M5Client
|
||||||
from .models import AppMode, FlashTarget
|
from .models import AppMode, FlashPreset, FlashTarget
|
||||||
from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen
|
from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -41,16 +41,21 @@ class ServiceApp(App):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._fw: Optional[FirmwareClient] = None
|
self._fw: Optional[FirmwareClient] = None
|
||||||
self._m5: Optional[M5Client] = None
|
self._m5: Optional[M5Client] = None
|
||||||
|
# «Липкий» выбор оператора на FlashScreen — переносится на следующую
|
||||||
|
# плату в рамках одного запуска TUI (см. FlashPreset docstring).
|
||||||
|
# Сбрасывается при перезапуске TUI, не персистится на диск.
|
||||||
|
self._last_flash_preset = FlashPreset()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.push_screen(WaitingScreen())
|
self.push_screen(WaitingScreen())
|
||||||
|
|
||||||
# ── Переходы между экранами ───────────────────────────────────────────────
|
# ── Переходы между экранами ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(WaitingScreen.DeviceDetected)
|
||||||
@on(WaitingScreen.DeviceDetected)
|
@on(WaitingScreen.DeviceDetected)
|
||||||
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
|
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
|
||||||
if event.mode == AppMode.FLASHING:
|
if event.mode == AppMode.FLASHING:
|
||||||
self.switch_screen(FlashScreen())
|
self.switch_screen(FlashScreen(preset=self._last_flash_preset))
|
||||||
elif event.mode == AppMode.DIAGNOSING:
|
elif event.mode == AppMode.DIAGNOSING:
|
||||||
self._connect_and_diagnose()
|
self._connect_and_diagnose()
|
||||||
|
|
||||||
|
|
@ -63,6 +68,9 @@ class ServiceApp(App):
|
||||||
- target=None — маркер потери соединения (см. ConnectionWatcherMixin)
|
- target=None — маркер потери соединения (см. ConnectionWatcherMixin)
|
||||||
→ WaitingScreen с явной причиной возврата
|
→ WaitingScreen с явной причиной возврата
|
||||||
"""
|
"""
|
||||||
|
if event.preset is not None:
|
||||||
|
self._last_flash_preset = event.preset
|
||||||
|
|
||||||
if event.target is None and not event.success:
|
if event.target is None and not event.success:
|
||||||
self.switch_screen(
|
self.switch_screen(
|
||||||
WaitingScreen(disconnect_reason="Соединение с платой потеряно")
|
WaitingScreen(disconnect_reason="Соединение с платой потеряно")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ AppFrame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 112;
|
max-width: 112;
|
||||||
max-height: 35;
|
max-height: 40;
|
||||||
border: heavy $primary;
|
border: heavy $primary;
|
||||||
background: $surface;
|
background: $surface;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
|
|
@ -88,19 +88,39 @@ AppFrame {
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
margin-bottom: 1;
|
margin-bottom: 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 18;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flash-custom-path {
|
#flash-target-group .section-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#flash-custom-group {
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flash-custom-path.hidden {
|
#flash-custom-group.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flash-custom-input {
|
#flash-custom-select,
|
||||||
|
#flash-fcb-select {
|
||||||
width: 1fr;
|
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 {
|
#flash-btn-row {
|
||||||
|
|
@ -131,6 +151,7 @@ AppFrame {
|
||||||
|
|
||||||
#flash-log {
|
#flash-log {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
|
min-height: 6;
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
border: round $panel;
|
border: round $panel;
|
||||||
background: $surface-darken-1;
|
background: $surface-darken-1;
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Awaitable, Callable, Optional
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
from .models import FlashProgress, FlashTarget
|
from .models import FcbVariant, FlashProgress, FlashTarget
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -39,6 +41,31 @@ _FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug")
|
||||||
# Путь до flash_usb.py относительно корня репозитория
|
# Путь до flash_usb.py относительно корня репозитория
|
||||||
_FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py"
|
_FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py"
|
||||||
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent
|
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent
|
||||||
|
_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 для извлечения прогресса
|
# Паттерны stdout flash_usb.py для извлечения прогресса
|
||||||
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
|
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
|
||||||
|
|
@ -108,6 +135,11 @@ class Flasher:
|
||||||
"""True если виден CDC firmware_test (1996:00AD)."""
|
"""True если виден CDC firmware_test (1996:00AD)."""
|
||||||
return _detect_usb(_CDC_VID, _CDC_PID)
|
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 ────────────────────────────────────────────────────────────────
|
# ── Erase ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def erase_chip(
|
async def erase_chip(
|
||||||
|
|
@ -138,6 +170,8 @@ class Flasher:
|
||||||
target: FlashTarget,
|
target: FlashTarget,
|
||||||
progress_cb: Optional[ProgressCallback] = None,
|
progress_cb: Optional[ProgressCallback] = None,
|
||||||
bin_path: Optional[Path] = None,
|
bin_path: Optional[Path] = None,
|
||||||
|
use_dcd: bool = False,
|
||||||
|
fcb_variant: FcbVariant = FcbVariant.W25Q128,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Запустить прошивку через flash_usb.py.
|
Запустить прошивку через flash_usb.py.
|
||||||
|
|
@ -145,6 +179,10 @@ class Flasher:
|
||||||
:param target: Что прошиваем (firmware_test, production или custom).
|
:param target: Что прошиваем (firmware_test, production или custom).
|
||||||
:param progress_cb: Async callback с FlashProgress (может быть None).
|
:param progress_cb: Async callback с FlashProgress (может быть None).
|
||||||
:param bin_path: Путь к бинарю (обязателен для CUSTOM).
|
: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 при успехе.
|
:return: True при успехе.
|
||||||
"""
|
"""
|
||||||
if target == FlashTarget.FIRMWARE_TEST:
|
if target == FlashTarget.FIRMWARE_TEST:
|
||||||
|
|
@ -159,9 +197,144 @@ class Flasher:
|
||||||
elif target == FlashTarget.CUSTOM:
|
elif target == FlashTarget.CUSTOM:
|
||||||
if bin_path is None:
|
if bin_path is None:
|
||||||
raise ValueError("FlashTarget.CUSTOM требует bin_path")
|
raise ValueError("FlashTarget.CUSTOM требует bin_path")
|
||||||
return await self._run_flash_bin(bin_path, progress_cb)
|
return await self._run_flash_custom(
|
||||||
|
bin_path, use_dcd, fcb_variant, progress_cb
|
||||||
|
)
|
||||||
return False
|
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(
|
async def _run_flash(
|
||||||
self,
|
self,
|
||||||
firmware: str,
|
firmware: str,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,49 @@ class FlashTarget(Enum):
|
||||||
CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно
|
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)
|
@dataclass(frozen=True)
|
||||||
class TestInfo:
|
class TestInfo:
|
||||||
"""Метаданные теста из list_tests."""
|
"""Метаданные теста из list_tests."""
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,17 @@ from textual.message import Message
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Button,
|
Button,
|
||||||
Input,
|
|
||||||
Label,
|
Label,
|
||||||
Log,
|
Log,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
RadioButton,
|
RadioButton,
|
||||||
RadioSet,
|
RadioSet,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..flasher import Flasher
|
from ..flasher import CUSTOM_BINARIES_DIR, Flasher
|
||||||
from ..models import FlashProgress, FlashTarget
|
from ..models import FcbVariant, FlashPreset, FlashProgress, FlashTarget
|
||||||
from ..widgets import AppFrame
|
from ..widgets import AppFrame
|
||||||
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
||||||
|
|
||||||
|
|
@ -56,15 +57,22 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
]
|
]
|
||||||
|
|
||||||
class FlashDone(Message):
|
class FlashDone(Message):
|
||||||
def __init__(self, success: bool, target: Optional[FlashTarget] = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
success: bool,
|
||||||
|
target: Optional[FlashTarget] = None,
|
||||||
|
preset: Optional[FlashPreset] = None,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.success = success
|
self.success = success
|
||||||
self.target = target
|
self.target = target
|
||||||
|
self.preset = preset
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, preset: Optional[FlashPreset] = None, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._flasher = Flasher()
|
self._flasher = Flasher()
|
||||||
self._flashing = False
|
self._flashing = False
|
||||||
|
self._preset = preset or FlashPreset()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with AppFrame(id="flash-frame"):
|
with AppFrame(id="flash-frame"):
|
||||||
|
|
@ -79,21 +87,37 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
yield RadioButton(
|
yield RadioButton(
|
||||||
"Диагностическая прошивка (firmware_test)",
|
"Диагностическая прошивка (firmware_test)",
|
||||||
id="radio-fw-test",
|
id="radio-fw-test",
|
||||||
value=True,
|
value=self._preset.target == FlashTarget.FIRMWARE_TEST,
|
||||||
)
|
)
|
||||||
yield RadioButton(
|
yield RadioButton(
|
||||||
"Серийная прошивка (bootloader + tft_app)",
|
"Серийная прошивка (bootloader + tft_app)",
|
||||||
id="radio-production",
|
id="radio-production",
|
||||||
|
value=self._preset.target == FlashTarget.PRODUCTION,
|
||||||
)
|
)
|
||||||
yield RadioButton(
|
yield RadioButton(
|
||||||
"Другое",
|
"Другое",
|
||||||
id="radio-custom",
|
id="radio-custom",
|
||||||
|
value=self._preset.target == FlashTarget.CUSTOM,
|
||||||
)
|
)
|
||||||
with Horizontal(id="flash-custom-path", classes="hidden"):
|
is_custom = self._preset.target == FlashTarget.CUSTOM
|
||||||
yield Input(
|
with Vertical(
|
||||||
placeholder="Имя бинарного файла в custom_binaries/ (.bin)",
|
id="flash-custom-group",
|
||||||
id="flash-custom-input",
|
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"):
|
with Horizontal(id="flash-btn-row"):
|
||||||
yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning")
|
yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning")
|
||||||
|
|
@ -112,6 +136,17 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._start_connection_watch(self._check_sdp_present)
|
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:
|
def on_unmount(self) -> None:
|
||||||
self._stop_connection_watch()
|
self._stop_connection_watch()
|
||||||
|
|
@ -132,11 +167,11 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
@on(RadioSet.Changed, "#flash-radio")
|
@on(RadioSet.Changed, "#flash-radio")
|
||||||
def _on_radio_changed(self, event: RadioSet.Changed) -> None:
|
def _on_radio_changed(self, event: RadioSet.Changed) -> None:
|
||||||
is_custom = event.pressed.id == "radio-custom"
|
is_custom = event.pressed.id == "radio-custom"
|
||||||
path_row = self.query_one("#flash-custom-path")
|
group = self.query_one("#flash-custom-group")
|
||||||
if is_custom:
|
if is_custom:
|
||||||
path_row.remove_class("hidden")
|
group.remove_class("hidden")
|
||||||
else:
|
else:
|
||||||
path_row.add_class("hidden")
|
group.add_class("hidden")
|
||||||
|
|
||||||
@on(Button.Pressed, "#flash-btn-flash")
|
@on(Button.Pressed, "#flash-btn-flash")
|
||||||
def _on_flash_pressed(self) -> None:
|
def _on_flash_pressed(self) -> None:
|
||||||
|
|
@ -144,9 +179,20 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
return
|
return
|
||||||
target, bin_path = self._resolve_target()
|
target, bin_path = self._resolve_target()
|
||||||
if target is None:
|
if target is None:
|
||||||
self._log("⚠ Укажите корректное имя бинарного файла")
|
self._log("⚠ Выберите файл в custom_binaries/")
|
||||||
return
|
return
|
||||||
self._do_flash(target, bin_path)
|
|
||||||
|
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")
|
@on(Button.Pressed, "#flash-btn-erase")
|
||||||
def _on_erase_pressed(self) -> None:
|
def _on_erase_pressed(self) -> None:
|
||||||
|
|
@ -165,19 +211,23 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
# ── Workers ───────────────────────────────────────────────────────────────
|
# ── Workers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@work(exclusive=True, thread=False)
|
@work(exclusive=True, thread=False)
|
||||||
async def _do_flash(self, target: FlashTarget, bin_path: Optional[Path]) -> None:
|
async def _do_flash(
|
||||||
|
self, target: FlashTarget, bin_path: Optional[Path], preset: FlashPreset
|
||||||
|
) -> None:
|
||||||
self._set_busy(True)
|
self._set_busy(True)
|
||||||
self._show_progress(True)
|
self._show_progress(True)
|
||||||
self._log(f"▶ Прошивка: {target.value}")
|
self._log(f"▶ Прошивка: {target.value}")
|
||||||
ok = await self._flasher.flash(
|
ok = await self._flasher.flash(
|
||||||
target=target,
|
target=target,
|
||||||
bin_path=bin_path,
|
bin_path=bin_path,
|
||||||
|
use_dcd=preset.use_dcd,
|
||||||
|
fcb_variant=preset.fcb_variant,
|
||||||
progress_cb=self._on_progress,
|
progress_cb=self._on_progress,
|
||||||
)
|
)
|
||||||
self._set_busy(False)
|
self._set_busy(False)
|
||||||
self._finish_progress(ok)
|
self._finish_progress(ok)
|
||||||
self._log("✅ Готово" if ok else "❌ Ошибка")
|
self._log("✅ Готово" if ok else "❌ Ошибка")
|
||||||
self.post_message(self.FlashDone(success=ok, target=target))
|
self.post_message(self.FlashDone(success=ok, target=target, preset=preset))
|
||||||
|
|
||||||
@work(exclusive=True, thread=False)
|
@work(exclusive=True, thread=False)
|
||||||
async def _do_erase(self) -> None:
|
async def _do_erase(self) -> None:
|
||||||
|
|
@ -200,16 +250,22 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||||
if pressed_id == "radio-production":
|
if pressed_id == "radio-production":
|
||||||
return FlashTarget.PRODUCTION, None
|
return FlashTarget.PRODUCTION, None
|
||||||
if pressed_id == "radio-custom":
|
if pressed_id == "radio-custom":
|
||||||
raw = self.query_one("#flash-custom-input", Input).value.strip()
|
name = self.query_one("#flash-custom-select", Select).value
|
||||||
if not raw:
|
if name is None or name is Select.BLANK:
|
||||||
return None, None
|
return None, None
|
||||||
p = Path(raw)
|
p = CUSTOM_BINARIES_DIR / str(name)
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
self._log(f"⚠ Файл не найден: {p}")
|
self._log(f"⚠ Файл не найден: {p}")
|
||||||
return None, None
|
return None, None
|
||||||
return FlashTarget.CUSTOM, p
|
return FlashTarget.CUSTOM, p
|
||||||
return None, None
|
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:
|
async def _on_progress(self, progress: FlashProgress) -> None:
|
||||||
bar = self.query_one("#flash-progress-bar", ProgressBar)
|
bar = self.query_one("#flash-progress-bar", ProgressBar)
|
||||||
bar.update(total=100, progress=progress.percent)
|
bar.update(total=100, progress=progress.percent)
|
||||||
|
|
|
||||||
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 |
|
|
@ -17,6 +17,8 @@ tools/production/
|
||||||
├── main.py ← точка входа (10 строк)
|
├── main.py ← точка входа (10 строк)
|
||||||
├── pyproject.toml ← зависимости uv
|
├── pyproject.toml ← зависимости uv
|
||||||
├── uv.lock
|
├── uv.lock
|
||||||
|
├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
|
||||||
|
│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
|
||||||
└── app/
|
└── app/
|
||||||
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
|
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
|
||||||
├── app.tcss ← единый файл стилей для всех экранов
|
├── app.tcss ← единый файл стилей для всех экранов
|
||||||
|
|
@ -202,7 +204,9 @@ flowchart TD
|
||||||
- При срабатывании — `ConnectionLost` message → экран постит
|
- При срабатывании — `ConnectionLost` message → экран постит
|
||||||
`FlashDone(success=False, target=None)` / `DiagDone(reason=...)` →
|
`FlashDone(success=False, target=None)` / `DiagDone(reason=...)` →
|
||||||
`ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает
|
`ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает
|
||||||
на `WaitingScreen(disconnect_reason=...)`.
|
на `WaitingScreen(disconnect_reason=...)`. `FlashDone` в этой ветке не несёт
|
||||||
|
`preset` — «липкий» выбор (см. §8) сохраняется отдельно, в момент нажатия
|
||||||
|
«Загрузить», а не при завершении прошивки.
|
||||||
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
|
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
|
||||||
продолжает обычный автодетект.
|
продолжает обычный автодетект.
|
||||||
|
|
||||||
|
|
@ -228,7 +232,6 @@ AppFrame {
|
||||||
```
|
```
|
||||||
|
|
||||||
Решает две задачи:
|
Решает две задачи:
|
||||||
|
|
||||||
1. **Визуальная консистентность** — одна и та же рамка на всех экранах.
|
1. **Визуальная консистентность** — одна и та же рамка на всех экранах.
|
||||||
2. **Устраняет краш Textual 8.x** при mouse drag
|
2. **Устраняет краш Textual 8.x** при mouse drag
|
||||||
(`assert isinstance(content_widget.parent, Widget)`) — раньше `Screen` мог
|
(`assert isinstance(content_widget.parent, Widget)`) — раньше `Screen` мог
|
||||||
|
|
@ -244,7 +247,110 @@ AppFrame {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Архитектура экранов
|
## 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
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
|
|
@ -292,7 +398,7 @@ graph TB
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Жизненный цикл диагностической сессии
|
## 10. Жизненный цикл диагностической сессии
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
|
|
@ -365,7 +471,7 @@ sequenceDiagram
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Версионирование firmware
|
## 11. Версионирование firmware
|
||||||
|
|
||||||
`firmware_test` версионируется через CMake
|
`firmware_test` версионируется через CMake
|
||||||
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
|
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
|
||||||
|
|
@ -382,7 +488,7 @@ sequenceDiagram
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Логотип (`boot_art.py`)
|
## 12. Логотип (`boot_art.py`)
|
||||||
|
|
||||||
`LOGO_ART` — Rich-markup строка (29×21 символов, цвета `#3ca0dc` для синей
|
`LOGO_ART` — Rich-markup строка (29×21 символов, цвета `#3ca0dc` для синей
|
||||||
части логотипа, `white` для тёмной, `grey37` для фоновых точек), полученная
|
части логотипа, `white` для тёмной, `grey37` для фоновых точек), полученная
|
||||||
|
|
@ -400,7 +506,7 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Известные грабли Textual 8.x
|
## 13. Известные грабли Textual 8.x
|
||||||
|
|
||||||
Зафиксировано на практике — экономит время при будущих доработках:
|
Зафиксировано на практике — экономит время при будущих доработках:
|
||||||
|
|
||||||
|
|
@ -451,3 +557,14 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
|
||||||
архитектурным решением, а не техдолгом.
|
архитектурным решением, а не техдолгом.
|
||||||
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
|
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
|
||||||
«копирование UID с экрана» — отложены, не начаты.
|
«копирование UID с экрана» — отложены, не начаты.
|
||||||
|
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту
|
||||||
|
детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем
|
||||||
|
понадобится полный батч-режим — потребуется отдельный предохранитель
|
||||||
|
(задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя
|
||||||
|
идентифицировать по UID.
|
||||||
|
- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили
|
||||||
|
не полагаться на него вообще, для кастомных бинарей FCB всегда пишется
|
||||||
|
явно (`--fcb-path`, см. §8.4). Остаётся не до конца понятым, работает ли
|
||||||
|
`configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос
|
||||||
|
снят с повестки архитектурным решением, а не исследован до конца.
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "service-tui"
|
name = "service-tui"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
|
description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "service-tui"
|
name = "service-tui"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue