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 0000000..336c7ae Binary files /dev/null and b/tools/production/custom_binaries/TFT_BOOTLOADER_NEW.bin differ diff --git a/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin b/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin new file mode 100644 index 0000000..f5a4692 Binary files /dev/null and b/tools/production/custom_binaries/TFT_BOOTLOADER_OLD.bin differ diff --git a/tools/production/docs/DEV_ARCH.md b/tools/production/docs/DEV_ARCH.md 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" },