From e50b2f35aaaedb5cc082662b25c66fb5037ad3d9 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Wed, 1 Jul 2026 16:54:10 +0300 Subject: [PATCH] # firmware_test: service-tui is ready for packing, docs updated --- docs/DEV_ARCH.md | 7 +- docs/HOW_TO_FLASH.md | 43 ++++- tools/host/README.md | 17 +- tools/host/flash_usb.py | 56 +++++- tools/production/README.md | 50 ++++- tools/production/app/app.py | 12 +- tools/production/app/app.tcss | 29 ++- tools/production/app/flasher.py | 177 +++++++++++++++++- tools/production/app/models.py | 43 +++++ tools/production/app/screens/flash.py | 96 ++++++++-- .../custom_binaries/TFT_BOOTLOADER_NEW.bin | Bin 0 -> 39676 bytes .../custom_binaries/TFT_BOOTLOADER_OLD.bin | Bin 0 -> 47432 bytes tools/production/docs/DEV_ARCH.md | 131 ++++++++++++- tools/production/pyproject.toml | 2 +- tools/production/uv.lock | 2 +- 15 files changed, 606 insertions(+), 59 deletions(-) create mode 100644 tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin create mode 100644 tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin diff --git a/docs/DEV_ARCH.md b/docs/DEV_ARCH.md index 26975b4..61bd5f8 100644 --- a/docs/DEV_ARCH.md +++ b/docs/DEV_ARCH.md @@ -194,9 +194,14 @@ flowchart LR │ │ ├── flash_usb.py ← USB SDP: sdphost + blhost │ │ ├── flash_swd.py ← SWD: FCB + HAB → pyOCD Flash │ │ ├── hab/ ← HAB yaml-конфиги (nxpimage) -│ │ ├── dcd/ ← w25q128_fdcb.bin, ivt_flashloader.bin +│ │ ├── dcd/ ← w25q128_fdcb.bin, w25q512_fdcb.bin, +│ │ │ dcd.bin, ivt_flashloader.bin │ │ └── uv.lock │ │ +│ ├── production/ ← service-tui: TUI сервисного инженера (Textual) +│ │ прошивка/диагностика готовых плат, см. +│ │ tools/production/README.md + DEV_ARCH.md +│ │ │ └── hil/ ← HIL pytest-окружение │ ├── conftest.py ← фикстуры: m5, loaded_, uart_ │ ├── pyocd_utils.py ← FLEXRAM init, ELF loader, run_from_vectors diff --git a/docs/HOW_TO_FLASH.md b/docs/HOW_TO_FLASH.md index fa5c11e..d47c9cb 100644 --- a/docs/HOW_TO_FLASH.md +++ b/docs/HOW_TO_FLASH.md @@ -72,7 +72,26 @@ flowchart TD ``` ROM-загрузчик сам конфигурирует FlexSPI через DCD из HAB-образа, поэтому FCB -в образе не нужен — его пишет Flashloader отдельно. +в образе не нужен — его пишет Flashloader отдельно. Это верно для W25Q128 +(текущая плата) — auto-config Flashloader для неё проверен на практике. +Для плат с другой памятью (W25Q256/512, 4-байтная адресация) надёжность +auto-config не подтверждена — см. 1.5. + +### 1.5 Нестандартная память (W25Q256/512) и сторонние бинарники + +`service-tui` (`tools/production/`) умеет прошивать бинарники, собранные не +в этом репозитории (например, старые платы с W25Q512), тем же способом +(USB SDP), но с двумя отличиями от штатного пути: + +- HAB-образ (IVT + опционально DCD) собирается из **сырого** бинарника на + лету через `nxpimage`, а не заранее через `just build::hab-*` +- FCB пишется **явно** (`flash_usb.py --fcb-path tools/host/dcd/w25qXXX_fdcb.bin`, + буквальный `write-memory` вместо `configure-memory 0xF000000F`) — auto-config + для 4-байтной адресации не проверялся, решили на него не полагаться + +Подробности конвейера — в `tools/production/DEV_ARCH.md`, §8. Штатный путь +(`--firmware`, три сборки этого репозитория) не меняется и по-прежнему +использует auto-config Flashloader, как описано в 1.4. --- @@ -141,15 +160,19 @@ FCB-бинарник (`w25q128_fdcb.bin`) генерируется в NXP Secure ## Сравнение способов -| | USB SDP | SWD | -| ------------------------- | -------------------------------- | -------------------- | -| Переключение BOOT_MODE | Нужно | Не нужно | -| Power cycle после записи | Не нужен | **Обязателен** | -| FCB в образе | Не нужен (Flashloader пишет сам) | **Обязателен** | -| Скорость записи | ~50–100 kB/s | ~8–10 kB/s | -| Совместимость с отладкой | Раздельно | MCU-Link монопольный | -| Производственный сценарий | ✓ | — | -| Итеративная разработка | Неудобно (смена режима) | ✓ | +| | USB SDP | SWD | +| ------------------------- | ----------------------- | -------------------- | +| Переключение BOOT_MODE | Нужно | Не нужно | +| Power cycle после записи | Не нужен | **Обязателен** | +| FCB в образе | Не нужен для W25Q128* | **Обязателен** | +| Скорость записи | ~50–100 kB/s | ~8–10 kB/s | +| Совместимость с отладкой | Раздельно | MCU-Link монопольный | +| Производственный сценарий | ✓ | — | +| Итеративная разработка | Неудобно (смена режима) | ✓ | + +\* Flashloader пишет FCB сам через auto-config — проверено для W25Q128. +Для сторонних бинарников с другой памятью `service-tui` пишет FCB явно, +см. 1.5. --- diff --git a/tools/host/README.md b/tools/host/README.md index 32fc649..40a326f 100644 --- a/tools/host/README.md +++ b/tools/host/README.md @@ -12,21 +12,28 @@ Python-окружение на базе [uv](https://docs.astral.sh/uv/) для ```bash tools/host/ ├── flash_usb.py — прошивка через USB ROM: sdphost → Flashloader → Flash +│ (+ --bin-path/--fcb-path — сторонние образы с явным +│ FCB, вызывается из service-tui, см. tools/production/) ├── flash_swd.py — прошивка через SWD: FCB + HAB → pyOCD → Flash -├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип) +├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип; +│ service-tui генерирует такие же временно, на лету — +│ см. tools/production/DEV_ARCH.md, §8) ├── dcd/ │ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP) │ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P) │ ├── w25q128_fdcb.bin — FCB для W25Q128 Quad SPI ← используется │ ├── w25q64_fdcb.bin — FCB для W25Q64 Quad SPI -│ └── w25q512_fdcb.bin — FCB для W25Q512 Quad SPI +│ └── w25q512_fdcb.bin — FCB для W25Q512 Quad SPI ← используется ├── ../../docs/mimxrt1052/HAB_GUIDE.md — подробно про HAB-образы и процесс подписи ├── pyproject.toml └── uv.lock ``` > Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся -> в репозитории — пересоздавать не нужно. +> в репозитории — пересоздавать не нужно. `w25q128`/`w25q512` — единственные +> два варианта в реальном использовании (64 и 256 сведены к ним же, см. +> `tools/production/DEV_ARCH.md`, §8.2); `w25q64_fdcb.bin` пока не подключён +> нигде — оставлен про запас. --- @@ -35,6 +42,10 @@ tools/host/ Подробное описание обоих способов прошивки — в `docs/HOW_TO_FLASH.md`. Сравнительная таблица, карта Flash, диагностика — там же. +Прошивка сторонних/легаси бинарников с нестандартной памятью (явный FCB, +без auto-config) — через `service-tui` (`tools/production/`), не напрямую +через `flash_usb.py` из терминала. Детали конвейера — `tools/production/DEV_ARCH.md`, §8. + --- ## Быстрый старт diff --git a/tools/host/flash_usb.py b/tools/host/flash_usb.py index 642aa0b..76e8b6a 100644 --- a/tools/host/flash_usb.py +++ b/tools/host/flash_usb.py @@ -76,6 +76,8 @@ FLEXSPI_FCB_VALUE = "0xF000000F" FLASH_BASE = 0x60000000 HAB_OFFSET = 0x1000 # IVT offset: write address = FLASH_BASE + HAB_OFFSET +ERASE_ALL_TIMEOUT_MS = "200000" # W25Q512 стирается заметно дольше W25Q128 + # ─── Helpers ────────────────────────────────────────────────────────────────── @@ -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.""" if not hab_bin.exists(): 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( [ @@ -318,7 +352,7 @@ def erase_chip() -> None: [ "blhost", "-t", - "100000", + ERASE_ALL_TIMEOUT_MS, "-u", BLHOST_USB, "--", @@ -342,6 +376,17 @@ def main() -> None: epilog=__doc__, ) + parser.add_argument( + "--fcb-path", + type=Path, + metavar="PATH", + default=None, + help=( + "Явный FCB-блоб (512 байт) для записи в 0x60000000 вместо " + "auto-config Flashloader. Имеет смысл только с --bin-path." + ), + ) + # Группа: что прошивать (взаимоисключающие варианты) target_group = parser.add_mutually_exclusive_group() target_group.add_argument( @@ -381,6 +426,9 @@ def main() -> None: if args.erase_chip and args.ram_only: parser.error("--erase-chip несовместим с --ram-only") + if args.fcb_path is not None and args.bin_path is None: + parser.error("--fcb-path имеет смысл только вместе с --bin-path") + if args.firmware is None and args.bin_path is None and not args.erase_chip: parser.error("Укажи --firmware, --bin-path или --erase-chip") @@ -413,7 +461,7 @@ def main() -> None: if args.erase_chip: erase_chip() 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__": diff --git a/tools/production/README.md b/tools/production/README.md index 4a7226d..395117a 100644 --- a/tools/production/README.md +++ b/tools/production/README.md @@ -45,13 +45,18 @@ TUI не пытается восстановить прежнее состоян │ ◉ Диагностическая прошивка (firmware_test) │ │ ○ Серийная прошивка (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) @@ -146,11 +169,25 @@ Production/Custom этот шаг не нужен). 5. TUI автоматически попадает в DiagScreen при следующем подключении ``` +### Прошивка стороннего бинарника (custom_binaries/) + +Для плат старых ревизий и любых образов, собранных не в этом репозитории. + +```bash +1. Положить сырой бинарник (без FCB/IVT/DCD) в custom_binaries/ + (или в директорию из SERVICE_CUSTOM_BINARIES_DIR) +2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen +3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD при необходимости +4. Загрузить — TUI сама соберёт HAB-образ и запишет правильный FCB +5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже + подставлен, останется нажать «Загрузить» +``` + ### Chip Erase ```bash 1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3) -2. FlashScreen → Очистить память (~30 с) +2. FlashScreen → Очистить память (~30 с для W25Q128, дольше для W25Q512) 3. После erase BootROM не загрузит прошивку — требуется перепрошить ``` @@ -175,6 +212,11 @@ SERVICE_CDC_PID=00ad SERVICE_M5_VID=303a SERVICE_M5_PID=4001 +# Директория с сырыми кастомными бинарниками для FlashScreen → "Другое". +# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом +# с main.py в dev-режиме); создаётся автоматически при старте. +# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries + # Тип сборки firmware_test для прошивки (Debug | Release). # Release временно нестабилен — по умолчанию Debug. FIRMWARE_BUILD_TYPE=Debug diff --git a/tools/production/app/app.py b/tools/production/app/app.py index f8085ca..4adfe94 100644 --- a/tools/production/app/app.py +++ b/tools/production/app/app.py @@ -17,7 +17,7 @@ from textual.binding import Binding from .firmware_client import FirmwareClient from .m5_client import M5Client -from .models import AppMode, FlashTarget +from .models import AppMode, FlashPreset, FlashTarget from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen logger = logging.getLogger(__name__) @@ -41,16 +41,21 @@ class ServiceApp(App): super().__init__() self._fw: Optional[FirmwareClient] = None self._m5: Optional[M5Client] = None + # «Липкий» выбор оператора на FlashScreen — переносится на следующую + # плату в рамках одного запуска TUI (см. FlashPreset docstring). + # Сбрасывается при перезапуске TUI, не персистится на диск. + self._last_flash_preset = FlashPreset() def on_mount(self) -> None: self.push_screen(WaitingScreen()) # ── Переходы между экранами ─────────────────────────────────────────────── + @on(WaitingScreen.DeviceDetected) @on(WaitingScreen.DeviceDetected) def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None: if event.mode == AppMode.FLASHING: - self.switch_screen(FlashScreen()) + self.switch_screen(FlashScreen(preset=self._last_flash_preset)) elif event.mode == AppMode.DIAGNOSING: self._connect_and_diagnose() @@ -63,6 +68,9 @@ class ServiceApp(App): - target=None — маркер потери соединения (см. ConnectionWatcherMixin) → WaitingScreen с явной причиной возврата """ + if event.preset is not None: + self._last_flash_preset = event.preset + if event.target is None and not event.success: self.switch_screen( WaitingScreen(disconnect_reason="Соединение с платой потеряно") diff --git a/tools/production/app/app.tcss b/tools/production/app/app.tcss index 621a2ae..caaf5cb 100644 --- a/tools/production/app/app.tcss +++ b/tools/production/app/app.tcss @@ -16,7 +16,7 @@ AppFrame { width: 100%; height: 100%; max-width: 112; - max-height: 35; + max-height: 40; border: heavy $primary; background: $surface; padding: 1 2; @@ -88,19 +88,39 @@ AppFrame { padding: 1 2; margin-bottom: 1; 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; height: auto; } -#flash-custom-path.hidden { +#flash-custom-group.hidden { display: none; } -#flash-custom-input { +#flash-custom-select, +#flash-fcb-select { width: 1fr; + margin-bottom: 1; +} + +#flash-dcd-row { + height: auto; + align: left middle; + margin-top: 1; +} + +#flash-dcd-switch { + margin-right: 1; } #flash-btn-row { @@ -131,6 +151,7 @@ AppFrame { #flash-log { height: 1fr; + min-height: 6; margin-top: 1; border: round $panel; background: $surface-darken-1; diff --git a/tools/production/app/flasher.py b/tools/production/app/flasher.py index 3d260b2..ef041c2 100644 --- a/tools/production/app/flasher.py +++ b/tools/production/app/flasher.py @@ -19,10 +19,12 @@ import asyncio import logging import os import re +import sys +import tempfile from pathlib import Path from typing import Awaitable, Callable, Optional -from .models import FlashProgress, FlashTarget +from .models import FcbVariant, FlashProgress, FlashTarget logger = logging.getLogger(__name__) @@ -39,6 +41,31 @@ _FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug") # Путь до flash_usb.py относительно корня репозитория _FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py" _HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent +_HAB_DIR = _HOST_TOOLS_DIR / "hab" +_DCD_DIR = _HOST_TOOLS_DIR / "dcd" + + +def _resolve_custom_binaries_dir() -> Path: + """ + Директория с «сырыми» кастомными бинарниками для FlashScreen. + + Не пакуется в PyInstaller-бандл — внешняя директория, путь к которой + можно переопределить через SERVICE_CUSTOM_BINARIES_DIR. sys.executable + указывает на реальный exe и для --onefile, и для --onedir (в отличие + от sys._MEIPASS — временной распаковки onefile). + """ + override = os.environ.get("SERVICE_CUSTOM_BINARIES_DIR") + if override: + base = Path(override) + elif getattr(sys, "frozen", False): + base = Path(sys.executable).resolve().parent / "custom_binaries" + else: + base = Path(__file__).parents[1] / "custom_binaries" + base.mkdir(parents=True, exist_ok=True) + return base + + +CUSTOM_BINARIES_DIR = _resolve_custom_binaries_dir() # Паттерны stdout flash_usb.py для извлечения прогресса _RE_PERCENT = re.compile(r"(\d{1,3})\s*%") @@ -108,6 +135,11 @@ class Flasher: """True если виден CDC firmware_test (1996:00AD).""" return _detect_usb(_CDC_VID, _CDC_PID) + @staticmethod + def list_custom_binaries() -> list[Path]: + """Отсканировать custom_binaries/ на *.bin, отсортировано по имени.""" + return sorted(CUSTOM_BINARIES_DIR.glob("*.bin")) + # ── Erase ──────────────────────────────────────────────────────────────── async def erase_chip( @@ -138,6 +170,8 @@ class Flasher: target: FlashTarget, progress_cb: Optional[ProgressCallback] = None, bin_path: Optional[Path] = None, + use_dcd: bool = False, + fcb_variant: FcbVariant = FcbVariant.W25Q128, ) -> bool: """ Запустить прошивку через flash_usb.py. @@ -145,6 +179,10 @@ class Flasher: :param target: Что прошиваем (firmware_test, production или custom). :param progress_cb: Async callback с FlashProgress (может быть None). :param bin_path: Путь к бинарю (обязателен для CUSTOM). + :param use_dcd: Только для CUSTOM — включить DCDFilePath (SDRAM-init) + при сборке HAB-образа через nxpimage. + :param fcb_variant: Только для CUSTOM — какой явный FCB-блоб (dcd/*_fdcb.bin) + записать в Flash[0x60000000] вместо auto-config. :return: True при успехе. """ if target == FlashTarget.FIRMWARE_TEST: @@ -159,9 +197,144 @@ class Flasher: elif target == FlashTarget.CUSTOM: if bin_path is None: 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 + async def _run_flash_custom( + self, + raw_bin_path: Path, + use_dcd: bool, + fcb_variant: FcbVariant, + progress_cb: Optional[ProgressCallback], + ) -> bool: + """ + Прошить «сырой» (не-HAB) кастомный бинарник из custom_binaries/. + + Два шага: + 1. Собрать HAB-образ (IVT + опционально DCD, БЕЗ FCB) через + nxpimage — так же, как just build::hab-* собирает штатные + прошивки, только конфиг генерируется на лету под выбранный файл. + 2. Прошить получившийся HAB-образ через flash_usb.py --bin-path, + подставив явный FCB-блоб (--fcb-path) под выбранный тип памяти — + см. FcbVariant. + """ + if progress_cb is not None: + await progress_cb( + FlashProgress( + phase="nxpimage", percent=0, message="Сборка HAB-образа..." + ) + ) + + hab_bin = await self._build_custom_hab(raw_bin_path, use_dcd, progress_cb) + if hab_bin is None: + if progress_cb is not None: + await progress_cb( + FlashProgress( + phase="error", + percent=0, + message="Ошибка сборки HAB-образа (nxpimage)", + ) + ) + return False + + fcb_path = _DCD_DIR / fcb_variant.fcb_filename + try: + cmd = [ + "uv", + "run", + "--directory", + str(_HOST_TOOLS_DIR), + "python", + str(_FLASH_USB_SCRIPT), + "--bin-path", + str(hab_bin), + "--fcb-path", + str(fcb_path), + ] + return await self._run_cmd(cmd, raw_bin_path.stem, progress_cb) + finally: + hab_bin.unlink(missing_ok=True) + + async def _build_custom_hab( + self, + raw_bin: Path, + use_dcd: bool, + progress_cb: Optional[ProgressCallback] = None, + ) -> Optional[Path]: + """ + Собрать HAB-образ из сырого бинарника через nxpimage. + + Временный YAML пишется прямо в tools/host/hab/ (как и штатные + hab_*.yaml) и nxpimage запускается с cwd=tools/host/hab/ — это + обязательно: относительный DCDFilePath ("../dcd/dcd.bin") в + существующих конфигах резолвится именно так (см. build.just, + группа hab_image_gen — `cd tools/host/hab && uv run nxpimage ...`). + Отходить от этой схемы рискованно — nxpimage не документирует + явно, от чего резолвит относительные пути. + + :return: путь к собранному *.hab.bin, либо None при ошибке nxpimage. + """ + yaml_f = tempfile.NamedTemporaryFile( + dir=_HAB_DIR, suffix=".yaml", prefix="_tui_custom_", delete=False + ) + yaml_path = Path(yaml_f.name) + out_path = yaml_path.with_suffix(".hab.bin") + + lines = [ + "options:", + " flags: 0x00", + " startAddress: 0x60000000", + " ivtOffset: 0x1000", + " initialLoadSize: 0x2000", + " family: mimxrt1050", + ] + if use_dcd: + lines.append(" DCDFilePath: ../dcd/dcd.bin") + lines.append(f'inputImageFile: "{raw_bin.resolve()}"') + lines.append("sections: []") + + try: + yaml_f.write("\n".join(lines).encode("utf-8")) + yaml_f.close() + + cmd = [ + "uv", + "run", + "nxpimage", + "hab", + "export", + "--force", + "-c", + str(yaml_path), + "-o", + str(out_path), + ] + logger.info("Building custom HAB image: %s", " ".join(cmd)) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(_HAB_DIR), + ) + assert proc.stdout is not None + async for raw_line in proc.stdout: + line = raw_line.decode("utf-8", errors="replace").rstrip() + logger.debug("nxpimage: %s", line) + if progress_cb is not None and line: + await progress_cb( + FlashProgress(phase="nxpimage", percent=0, message=line) + ) + await proc.wait() + + if proc.returncode != 0 or not out_path.exists(): + logger.error("nxpimage hab export failed (rc=%s)", proc.returncode) + return None + return out_path + finally: + yaml_path.unlink(missing_ok=True) + async def _run_flash( self, firmware: str, diff --git a/tools/production/app/models.py b/tools/production/app/models.py index 519b3c8..cdf4a60 100644 --- a/tools/production/app/models.py +++ b/tools/production/app/models.py @@ -37,6 +37,49 @@ class FlashTarget(Enum): CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно +class FcbVariant(str, Enum): + """Вариант FCB для кастомных бинарей. + + W25Q128/W25Q64 (3-байтная адресация) и W25Q256/W25Q512 (4-байтная) + сведены к двум случаям — см. обсуждение прошивки старых плат. + """ + + W25Q128 = "w25q128" + W25Q512 = "w25q512" + + @property + def fcb_filename(self) -> str: + """Имя файла в tools/host/dcd/, соответствующее варианту.""" + return f"{self.value}_fdcb.bin" + + @property + def display_name(self) -> str: + return { + FcbVariant.W25Q128: "W25Q128 / W25Q64", + FcbVariant.W25Q512: "W25Q256 / W25Q512", + }[self] + + +@dataclass +class FlashPreset: + """«Липкий» выбор оператора на FlashScreen. + + Живёт в памяти ServiceApp (не на диске), переносится на следующую + плату в рамках одного запуска TUI — чтобы не выбирать заново файл + и опции при прошивке партии одинаковых плат. Сбрасывается при + перезапуске TUI. Обновляется в момент нажатия «Загрузить» (не только + при успехе — неудача чаще всего про USB-кабель, а не про то, что + выбор был неверным). + + DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM. + """ + + target: FlashTarget = FlashTarget.FIRMWARE_TEST + custom_bin_name: Optional[str] = None + use_dcd: bool = False + fcb_variant: FcbVariant = FcbVariant.W25Q128 + + @dataclass(frozen=True) class TestInfo: """Метаданные теста из list_tests.""" diff --git a/tools/production/app/screens/flash.py b/tools/production/app/screens/flash.py index 96c3782..9ba872b 100644 --- a/tools/production/app/screens/flash.py +++ b/tools/production/app/screens/flash.py @@ -20,16 +20,17 @@ from textual.message import Message from textual.screen import Screen from textual.widgets import ( Button, - Input, Label, Log, ProgressBar, RadioButton, RadioSet, + Select, + Switch, ) -from ..flasher import Flasher -from ..models import FlashProgress, FlashTarget +from ..flasher import CUSTOM_BINARIES_DIR, Flasher +from ..models import FcbVariant, FlashPreset, FlashProgress, FlashTarget from ..widgets import AppFrame from .connection_watcher import ConnectionLost, ConnectionWatcherMixin @@ -56,15 +57,22 @@ class FlashScreen(Screen, ConnectionWatcherMixin): ] 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__() self.success = success self.target = target + self.preset = preset - def __init__(self, **kwargs) -> None: + def __init__(self, preset: Optional[FlashPreset] = None, **kwargs) -> None: super().__init__(**kwargs) self._flasher = Flasher() self._flashing = False + self._preset = preset or FlashPreset() def compose(self) -> ComposeResult: with AppFrame(id="flash-frame"): @@ -79,21 +87,37 @@ class FlashScreen(Screen, ConnectionWatcherMixin): yield RadioButton( "Диагностическая прошивка (firmware_test)", id="radio-fw-test", - value=True, + value=self._preset.target == FlashTarget.FIRMWARE_TEST, ) yield RadioButton( "Серийная прошивка (bootloader + tft_app)", id="radio-production", + value=self._preset.target == FlashTarget.PRODUCTION, ) yield RadioButton( "Другое", id="radio-custom", + value=self._preset.target == FlashTarget.CUSTOM, ) - with Horizontal(id="flash-custom-path", classes="hidden"): - yield Input( - placeholder="Имя бинарного файла в custom_binaries/ (.bin)", - id="flash-custom-input", + is_custom = self._preset.target == FlashTarget.CUSTOM + with Vertical( + id="flash-custom-group", + classes="" if is_custom else "hidden", + ): + yield Label("Файл (custom_binaries/)", classes="section-title") + yield Select[str]( + [], id="flash-custom-select", prompt="Выберите файл..." ) + yield Label("Память платы", classes="section-title") + yield Select[str]( + [(v.display_name, v.value) for v in FcbVariant], + id="flash-fcb-select", + value=self._preset.fcb_variant.value, + allow_blank=False, + ) + with Horizontal(id="flash-dcd-row"): + yield Switch(value=self._preset.use_dcd, id="flash-dcd-switch") + yield Label("Использует SDRAM (DCD)", classes="section-title") with Horizontal(id="flash-btn-row"): yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning") @@ -112,6 +136,17 @@ class FlashScreen(Screen, ConnectionWatcherMixin): def on_mount(self) -> None: self._start_connection_watch(self._check_sdp_present) + self._populate_custom_select() + + def _populate_custom_select(self) -> None: + select = self.query_one("#flash-custom-select", Select) + names = [p.name for p in Flasher.list_custom_binaries()] + select.set_options([(name, name) for name in names]) + if not names: + self._log(f"⚠ Пусто: {CUSTOM_BINARIES_DIR}") + return + if self._preset.custom_bin_name in names: + select.value = self._preset.custom_bin_name def on_unmount(self) -> None: self._stop_connection_watch() @@ -132,11 +167,11 @@ class FlashScreen(Screen, ConnectionWatcherMixin): @on(RadioSet.Changed, "#flash-radio") def _on_radio_changed(self, event: RadioSet.Changed) -> None: 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: - path_row.remove_class("hidden") + group.remove_class("hidden") else: - path_row.add_class("hidden") + group.add_class("hidden") @on(Button.Pressed, "#flash-btn-flash") def _on_flash_pressed(self) -> None: @@ -144,9 +179,20 @@ class FlashScreen(Screen, ConnectionWatcherMixin): return target, bin_path = self._resolve_target() if target is None: - self._log("⚠ Укажите корректное имя бинарного файла") + self._log("⚠ Выберите файл в custom_binaries/") 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") def _on_erase_pressed(self) -> None: @@ -165,19 +211,23 @@ class FlashScreen(Screen, ConnectionWatcherMixin): # ── Workers ─────────────────────────────────────────────────────────────── @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._show_progress(True) self._log(f"▶ Прошивка: {target.value}") ok = await self._flasher.flash( target=target, bin_path=bin_path, + use_dcd=preset.use_dcd, + fcb_variant=preset.fcb_variant, progress_cb=self._on_progress, ) self._set_busy(False) self._finish_progress(ok) self._log("✅ Готово" if ok else "❌ Ошибка") - self.post_message(self.FlashDone(success=ok, target=target)) + self.post_message(self.FlashDone(success=ok, target=target, preset=preset)) @work(exclusive=True, thread=False) async def _do_erase(self) -> None: @@ -200,16 +250,22 @@ class FlashScreen(Screen, ConnectionWatcherMixin): if pressed_id == "radio-production": return FlashTarget.PRODUCTION, None if pressed_id == "radio-custom": - raw = self.query_one("#flash-custom-input", Input).value.strip() - if not raw: + name = self.query_one("#flash-custom-select", Select).value + if name is None or name is Select.BLANK: return None, None - p = Path(raw) + p = CUSTOM_BINARIES_DIR / str(name) if not p.exists(): self._log(f"⚠ Файл не найден: {p}") return None, None return FlashTarget.CUSTOM, p return None, None + def _current_fcb_variant(self) -> FcbVariant: + return FcbVariant(self.query_one("#flash-fcb-select", Select).value) + + def _current_use_dcd(self) -> bool: + return self.query_one("#flash-dcd-switch", Switch).value + async def _on_progress(self, progress: FlashProgress) -> None: bar = self.query_one("#flash-progress-bar", ProgressBar) bar.update(total=100, progress=progress.percent) diff --git a/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin b/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin new file mode 100644 index 0000000000000000000000000000000000000000..336c7aec67d9fb13fca720414cb0302bde5b5ad5 GIT binary patch literal 39676 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 CUaH*y literal 0 HcmV?d00001 diff --git a/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin b/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5a469290f8312405ba9678b4fc812dd15610daf GIT binary patch literal 47432 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 SCNANwBw literal 0 HcmV?d00001 diff --git a/tools/production/docs/DEV_ARCH.md b/tools/production/docs/DEV_ARCH.md index 821a1a7..8e8563f 100644 --- a/tools/production/docs/DEV_ARCH.md +++ b/tools/production/docs/DEV_ARCH.md @@ -17,6 +17,8 @@ tools/production/ ├── main.py ← точка входа (10 строк) ├── pyproject.toml ← зависимости uv ├── uv.lock +├── custom_binaries/ ← runtime, gitignored, создаётся автоматически +│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое» └── app/ ├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов ├── app.tcss ← единый файл стилей для всех экранов @@ -202,7 +204,9 @@ flowchart TD - При срабатывании — `ConnectionLost` message → экран постит `FlashDone(success=False, target=None)` / `DiagDone(reason=...)` → `ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает - на `WaitingScreen(disconnect_reason=...)`. + на `WaitingScreen(disconnect_reason=...)`. `FlashDone` в этой ветке не несёт + `preset` — «липкий» выбор (см. §8) сохраняется отдельно, в момент нажатия + «Загрузить», а не при завершении прошивки. - `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем продолжает обычный автодетект. @@ -228,7 +232,6 @@ AppFrame { ``` Решает две задачи: - 1. **Визуальная консистентность** — одна и та же рамка на всех экранах. 2. **Устраняет краш Textual 8.x** при mouse drag (`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 -o , + │ │ cwd=tools/host/hab/ (обязательно — relative DCDFilePath + │ │ резолвится от этой директории, как в build.just) + │ └── стриминг stdout nxpimage в progress_cb (не только logger.debug — + │ иначе во время сборки лог FlashScreen выглядит «зависшим») + └── flash_usb.py --bin-path --fcb-path tools/host/dcd/{fcb_variant}_fdcb.bin + (временный .yaml и собранный HAB-образ удаляются после прошивки) +``` + +`dcd/dcd.bin` — один и тот же файл независимо от проекта (SEMC/SDRAM-init не +зависит от того, что именно исполняется), поэтому просто константный путь, +без вариантов. + +### 8.4 `flash_usb.py` — явная запись FCB вместо auto-config + +```python +def write_fcb_explicit(fcb_path: Path) -> None: + """write-memory 0x60000000 — буквальная запись 512-байтного + FCB-блоба (tag 'FCFB'), а не magic option word 0xF000000F. + Обязателен для кастомных бинарей — auto-config Flashloader проверен + только для W25Q128.""" +``` + +Активируется флагом `--fcb-path` (только вместе с `--bin-path`). Штатный +`--firmware`-путь (три сборки из `BUILD_DIR`) не тронут: без `--fcb-path` +поведение идентично тому, что было до этой доработки. + +Заодно увеличен таймаут `blhost` для `flash-erase-all` (chip erase) — +`-t 200000` вместо дефолтного: W25Q512 стирается заметно дольше W25Q128, +дефолтного таймаута `blhost` не хватало. `flash-erase-region` (стирание +пары секторов под FCB+HAB при обычной прошивке) не трогали — там масштаб +на порядки меньше, дефолта достаточно независимо от чипа. + +### 8.5 UI (`flash.py`) + +При выборе радиокнопки «Другое» появляется `Vertical#flash-custom-group`: +`Select` по содержимому `custom_binaries/` (пересканируется в `on_mount()`), +`Select` по `FcbVariant`, `Switch` DCD. Выбор любой ДРУГОЙ радиокнопки в том +же `RadioSet` автоматически скрывает группу — отдельного «Назад» не +потребовалось, это штатное поведение взаимоисключающего `RadioSet`. + +`#flash-target-group` ограничена `max-height: 18` с собственным скроллом — +без этого разросшаяся custom-группа (два `Select` + `Switch`) на маленьком +терминале выталкивала `#flash-log` почти до нулевой высоты. `#flash-log` +дополнительно защищён `min-height: 6` — лог гарантированно виден даже в +худшем случае. + +--- + +## 9. Архитектура экранов ```mermaid graph TB @@ -292,7 +398,7 @@ graph TB --- -## 9. Жизненный цикл диагностической сессии +## 10. Жизненный цикл диагностической сессии ```mermaid sequenceDiagram @@ -365,7 +471,7 @@ sequenceDiagram --- -## 10. Версионирование firmware +## 11. Версионирование firmware `firmware_test` версионируется через CMake (`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` для синей части логотипа, `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» и «копирование UID с экрана» — отложены, не начаты. +- **Массовое программирование** — решено НЕ делать авто-прошивку по факту + детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем + понадобится полный батч-режим — потребуется отдельный предохранитель + (задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя + идентифицировать по UID. +- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили + не полагаться на него вообще, для кастомных бинарей FCB всегда пишется + явно (`--fcb-path`, см. §8.4). Остаётся не до конца понятым, работает ли + `configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос + снят с повестки архитектурным решением, а не исследован до конца. + \ No newline at end of file diff --git a/tools/production/pyproject.toml b/tools/production/pyproject.toml index f319c69..20b5d72 100644 --- a/tools/production/pyproject.toml +++ b/tools/production/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "service-tui" -version = "0.1.1" +version = "0.1.0" description = "TUI сервисного инженера для диагностики платы MIMXRT1052" requires-python = ">=3.11" dependencies = [ diff --git a/tools/production/uv.lock b/tools/production/uv.lock index e4fccb2..b041381 100644 --- a/tools/production/uv.lock +++ b/tools/production/uv.lock @@ -200,7 +200,7 @@ wheels = [ [[package]] name = "service-tui" -version = "0.1.1" +version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "pyinstaller" },