# firmware_test: service-tui is ready for packing, docs updated

This commit is contained in:
Dmitry Akimov 2026-07-01 16:54:10 +03:00
parent 2a6256e745
commit e50b2f35aa
15 changed files with 606 additions and 59 deletions

View file

@ -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

View file

@ -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* | **Обязателен** |
| Скорость записи | ~50100 kB/s | ~810 kB/s | | Скорость записи | ~50100 kB/s | ~810 kB/s |
| Совместимость с отладкой | Раздельно | MCU-Link монопольный | | Совместимость с отладкой | Раздельно | MCU-Link монопольный |
| Производственный сценарий | ✓ | — | | Производственный сценарий | ✓ | — |
| Итеративная разработка | Неудобно (смена режима) | ✓ | | Итеративная разработка | Неудобно (смена режима) | ✓ |
\* Flashloader пишет FCB сам через auto-config — проверено для W25Q128.
Для сторонних бинарников с другой памятью `service-tui` пишет FCB явно,
см. 1.5.
--- ---

View file

@ -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.
--- ---
## Быстрый старт ## Быстрый старт

View file

@ -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,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(
[ [
@ -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__":

View file

@ -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

View file

@ -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="Соединение с платой потеряно")

View file

@ -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;

View file

@ -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,

View file

@ -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."""

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -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` для этих чипов корректно в принципе — вопрос
снят с повестки архитектурным решением, а не исследован до конца.

View file

@ -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 = [

View file

@ -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" },