diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf8b674..4fc1b48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,22 @@ name: Release -# Раздельные теги (FIRST_RELEASE_PLAN.md, Шаг 2.1): firmware_test и -# service-tui версионируются и релизятся независимо друг от друга. -# firmware-vX.Y.Z → publish-firmware (только firmware_test_hab.bin, Debug) -# tui-vX.Y.Z → publish-tui (service-tui-vX.Y.Z-{macos,windows}.zip) -# HAB firmware_test, вшиваемый в TUI-бандл, всегда собирается заново из -# текущего HEAD тега tui-v*, а не берётся из отдельного firmware-релиза — -# так проще и не тянет зависимость на чужой GitHub Release. +# Раздельные теги (bootloader — Фаза 5, firmware/bootloader/PLAN.md): +# firmware_test, bootloader и service-tui версионируются и релизятся +# независимо друг от друга. +# firmware-vX.Y.Z → publish-firmware (только firmware_test_hab.bin, Debug) +# bootloader-vX.Y.Z → publish-bootloader (только bootloader_hab.bin, Release, +# подписан ТЕСТОВЫМ HAB-ключом — см. +# firmware/bootloader/SIGNING_CEREMONY.md) +# tui-vX.Y.Z → publish-tui (service-tui-vX.Y.Z-{macos,windows}.zip) +# HAB firmware_test/bootloader, вшиваемые в TUI-бандл, всегда собираются заново +# из текущего HEAD тега tui-v*, а не берутся из отдельных релизов — так проще +# и не тянет зависимость на чужой GitHub Release. on: push: tags: - "tui-v*" - "firmware-v*" + - "bootloader-v*" workflow_dispatch: inputs: release_type: @@ -20,6 +25,7 @@ on: options: - tui - firmware + - bootloader default: tui concurrency: @@ -30,11 +36,12 @@ permissions: jobs: # ───────────────────────────────────────────────────────────────────────── - # firmware — собирает HAB Debug firmware_test. Нужна как для standalone - # firmware-релиза, так и для вшивания в TUI-бандл — выполняется всегда. + # firmware — собирает HAB Debug firmware_test И HAB Release bootloader. + # Нужна как для standalone firmware-/bootloader-релизов, так и для вшивания + # обоих образов в TUI-бандл (package-tui, Фаза 5) — выполняется всегда. # ───────────────────────────────────────────────────────────────────────── firmware: - name: Build firmware_test HAB (Debug) + name: Build firmware_test HAB (Debug) + bootloader HAB (Release) runs-on: ubuntu-latest timeout-minutes: 90 @@ -86,6 +93,30 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Проверить, что тег совпадает с версией в CMakeLists.txt (bootloader) + if: startsWith(github.ref, 'refs/tags/bootloader-v') + run: | + tag_version="${GITHUB_REF_NAME#bootloader-v}" + file_version=$(grep -m1 -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' firmware/bootloader/CMakeLists.txt | awk '{print $2}') + if [[ "$tag_version" != "$file_version" ]]; then + echo "::error::Тег ${GITHUB_REF_NAME} (версия ${tag_version}) не совпадает с VERSION в firmware/bootloader/CMakeLists.txt (${file_version})" + exit 1 + fi + echo " ✅ Версия тега совпадает с CMakeLists.txt: ${file_version}" + + - name: Собрать bootloader HAB (Release) + run: | + docker run --rm --user root -v "$GITHUB_WORKSPACE":/workspace -w /workspace \ + tft-devcontainer-ci:latest bash -lc 'just build::hab-bootloader-release' + + - name: Upload bootloader_hab.bin + uses: actions/upload-artifact@v4 + with: + name: bootloader-hab-release + path: build/Release/bootloader_hab.bin + if-no-files-found: error + retention-days: 14 + # ───────────────────────────────────────────────────────────────────────── # publish-firmware — только на тег firmware-v*, отдельный standalone-релиз # HAB-образа (для tools/host/flash_usb.py и ручной прошивки, не через TUI). @@ -116,6 +147,40 @@ jobs: --title "firmware_test ${GITHUB_REF_NAME#firmware-v}" \ --generate-notes + # ───────────────────────────────────────────────────────────────────────── + # publish-bootloader — только на тег bootloader-v*, отдельный standalone- + # релиз HAB-образа (для tools/host/flash_usb.py / SWD, не через TUI). + # ⚠️ Подписан ТЕСТОВЫМ HAB-ключом (dev/pre-series, HAB Open чип всё равно + # загрузит и unsigned) — реальная SRK-церемония не проведена, см. + # firmware/bootloader/SIGNING_CEREMONY.md. Явно проговорено в release notes, + # чтобы этот asset не приняли за production-подписанный постфактум. + # ───────────────────────────────────────────────────────────────────────── + publish-bootloader: + name: Publish bootloader release + needs: firmware + if: startsWith(github.ref, 'refs/tags/bootloader-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download bootloader_hab.bin + uses: actions/download-artifact@v4 + with: + name: bootloader-hab-release + path: release-assets/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + release-assets/bootloader_hab.bin \ + --title "bootloader ${GITHUB_REF_NAME#bootloader-v}" \ + --notes "⚠️ HAB-подпись — ТЕСТОВЫЙ ключ (dev/pre-series, HAB Open). Не production. Реальная SRK-церемония — firmware/bootloader/SIGNING_CEREMONY.md." + # ───────────────────────────────────────────────────────────────────────── # service-tui-{macos,windows} — упаковка PyInstaller-бандла с вшитым HAB # из job firmware. Запускается на тег tui-v* и на workflow_dispatch с @@ -144,6 +209,12 @@ jobs: name: firmware-hab-debug path: build/Debug/ + - name: Download bootloader_hab.bin + uses: actions/download-artifact@v4 + with: + name: bootloader-hab-release + path: build/Release/ + - name: Проверить, что тег совпадает с версией в pyproject.toml if: startsWith(github.ref, 'refs/tags/tui-v') run: | @@ -197,6 +268,12 @@ jobs: name: firmware-hab-debug path: build/Debug/ + - name: Download bootloader_hab.bin + uses: actions/download-artifact@v4 + with: + name: bootloader-hab-release + path: build/Release/ + - name: Проверить, что тег совпадает с версией в pyproject.toml if: startsWith(github.ref, 'refs/tags/tui-v') shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 93eb8ee..740c89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,19 +52,195 @@ ### Что отслеживать -- Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow через `just ci::build` или начинает запускать host-тесты. +- ~~Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow~~ — решено: job `test` уже запускает host-тесты (`just ci::test` → `just build::test-host-release`). - Отслеживать появление self-hosted runner, регулярных аппаратных прогонов или отчётов по HIL. - Проверять новые firmware-test модули в `firmware/test/src/tests/` и синхронные обновления протокола/документации. -- Следить за развитием BSP: RGB (частично закрыто display-тестом), bootloader или `tft_app` — обе директории всё ещё не заведены. -- Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log. -- Отслеживать мерж ветки `feature-tui-monolith` в `dev` — после мержа эту запись нужно закрыть датой и финальным диапазоном SHA. +- Следить за развитием BSP: RGB (частично закрыто display-тестом); `tft_app` — директория всё ещё не заведена (bootloader — реализован, Фазы 0–6, см. запись ниже). +- Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log (пример — точечный патч `fault_injection_hardening.c` под `#if defined(__arm__)`, bootloader Фаза 3, см. запись ниже). +- ~~Отслеживать мерж ветки `feature-tui-monolith` в `dev`~~ — смёржено (`b4c664f`), запись закрыта датой ниже. +- Отслеживать тег `bootloader-v*` — после первого релиза закрыть запись «bootloader: полная реализация» датой и финальным SHA (сейчас часть диапазона — незакоммиченные изменения рабочего дерева). -## [Не выпущено] — service-tui: монолитный spsdk-бэкенд, устойчивость к обрыву USB, упаковка PyInstaller +## [Не выпущено] — bootloader: полная реализация, Фазы 0–6 (MCUboot Direct-XIP, HAB, service-tui интеграция) -Диапазон: `1801f1beb959d610d31ee3dcd1f91046953117d4..22c40779ef0ec9911031d7a5272c4611b596d3e8` + незакоммиченные изменения рабочего дерева (документация) +Диапазон: `4644f21507d3..6c564f16388a` + незакоммиченные изменения рабочего дерева (HAB-подпись +тестовым ключом, Тир-0/Тир-1 верификация в service-tui, CI `bootloader-v*`, документация) +Сравнение: + +> `firmware/bootloader/` была пустой директорией на момент базового среза (см. "Что отслеживать" +> выше — теперь снята с наблюдения, кроме тега релиза). Диапазон охватывает всю реализацию с нуля до +> готовности к первому релизу (`VERSION 1.0.0`), шесть фаз согласно (уже удалённому после завершения, +> см. "Удалено" ниже) `firmware/bootloader/PLAN.md`. + +### Кратко + +- Загрузчик MIMXRT1052 реализован целиком: XIP из Flash, выбор и запуск `tft_app` из одного из двух + слотов (MCUboot Direct-XIP), обновление с microSD, устойчивость к зависшим образам (watchdog + + recovery), HAB-подпись Release-сборки, интеграция с `service-tui` для контроля производственной + прошивки. +- По пути на реальном железе найдено и исправлено более десятка багов — от неверной трактовки + регистров FlexSPI/SRC до архитектурных пробелов в чек-листах верификации; детали по фазам ниже. +- `firmware/bootloader/CMakeLists.txt` → `VERSION 1.0.0`; все 6 фаз аппаратно верифицированы. + +### Добавлено + +- **Фаза 0 — карта Flash.** `docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md` — смещения + `BOOTLOADER`/`SLOT_A`/`SLOT_B`, зафиксированы до написания кода. +- **Фаза 1 — скелет.** `firmware/bootloader/{CMakeLists.txt,src/main.c,src/cli.c,src/protocol.{c,h}}` — + bring-up (LED/tick/USB CDC), урезанный протокол (`ping`/`get_version`), HAB unsigned Debug-конфиг. +- **Фаза 2 — bootutil (Direct-XIP).** `mcuboot_port/` — шим `flash_area_*` над `bsp_qspi_flash`, + `sysflash.h`, `mcuboot_config.h` (TinyCrypt ECDSA-P256, `MCUBOOT_DIRECT_XIP_REVERT`), + `src/boot_select.{c,h}`. `test_stub/` — заглушка `tft_app` (два слота, разная линковка) для + аппаратной проверки выбора слота. +- **Фаза 3 — SD-путь установки.** `src/{update_policy,slot_version,sd_update}.{c,h}` — сканирование + microSD, установка в неактивный слот, top-level состояние «нет валидного образа»; + `bootloader_fatfs` (read-only FatFS); аппаратный watchdog (`bsp/wdog`). +- **Фаза 4 — SDRAM/QSPI smoke-test.** `bsp/sdram::bsp_sdram_configure()` — C-порт DCD (SEMC/CCM); + `bsp_qspi_decode_chip()` — идентификация чипа по JEDEC; `src/led_status.{c,h}` — единый словарь + LED-паттернов; `dev_sdram_test.c` (dev-only, `BOOTLOADER_DEV_DIAGNOSTICS`). +- **Фаза 6 — recovery.** `bsp/boot_state` — счётчик попыток загрузки в `SRC_GPR3` (переживает + watchdog-сброс, обнуляется на POR); `src/recovery.{c,h}` — чистая функция `recovery_decide()` + (таксономия отказов A–D); recovery-режим по `BSP_BUTTON_2` с ослабленным version-gate. +- **Фаза 5 — HAB Release + service-tui.** Тестовый HAB-ключ (`tools/host/hab/keys/`, схема NOCAK) — + `hab_bootloader_release.yaml` реально подписывает Release-образ (`flags=0x08`); + `tools/service_tui/app/bootloader_client.py` — CDC-клиент bootloader + (`get_smoke_status`/`get_qspi_info`); `tools/service_tui/app/screens/verify.py` — экран живой + проверки загрузчика после серийной прошивки (Тир-1); Тир-0 (readback-верификация записи, + `flash_backend._verify_written()`) — включена по умолчанию для любой прошивки через `service-tui`; + `firmware/bootloader/SIGNING_CEREMONY.md` — план настоящей production-подписи (HAB SRK + MCUboot + production-ключ) на будущее. + +### Изменено + +- **Фаза 3**: детект SD консолидирован на единый `PRSSTAT`; ранний сэмпл кнопки даунгрейда. +- **Фаза 4**: `board_mpu_init()` (общий для всех прошивок код) — добавлен Region 11 под NIC-301 + GPV-регистры (`0x41000000`, 8 МБ). +- **Фаза 5**: `Flasher.PRODUCTION` (`tools/service_tui/app/flasher.py`) сужен до сценария A (только + загрузчик) — бандл с `tft_app` (сценарий B) отложен до реализации `tft_app`; production жёстко + резолвит Release, независимо от переменной `FIRMWARE_BUILD_TYPE`. `just host::package-tui` — + новый явный гвард на `build/Release/bootloader_hab.bin`. `.github/workflows/release.yml` — тег + `bootloader-v*` (симметрично `firmware-v*`), общий job `firmware` теперь собирает и подписывает + Release-образ bootloader. +- Корневой `README.md` — статус bootloader `запланирован` → `реализован, v1.0.0`. + +### Исправлено + +- **Фаза 2** (3 бага, аппаратная верификация): `jump_to_image()` маскировал IRQ перед прыжком (не по + референсу NXP) — вешал `bsp_delay()` в любом целевом образе; `bsp_qspi_read()` не округлял + `IDATSZ` до кратного 4 при IP-чтении — контроллер недодавал слово на хвостах не кратной длины + (впервые проявилось на чтении хэша образа bootutil); `qspi_read_tail()` сравнивал + `IPRXFSTS.FILL` (watermark-юниты по 8 байт) напрямую со счётчиком слов — зависал на хвостах ровно + в 2 слова (чтение подписи ECDSA). +- **Фаза 3**: форсированный даунгрейд физически записывался, но не загружался бы (`boot_go()` всегда + выбирает более высокую версию) — добавлено поле `erase_previous_active`; `fih_panic_loop()` + (вендоренный bootutil) ронял `test-host-release` в CI на x86_64-раннере (`invalid instruction + mnemonic 'b'` — ARM/Thumb-only мнемоника, на arm64 devcontainer случайно ассемблировалась, + маскируя проблему); стабы `test_stub` не позиционно-независимы — линковка под конкретный слот + обязательна и для реального `tft_app`, не только для заглушки. +- **Фаза 6** (4 бага): счётчик попыток загрузки рос и на пустой плате без SD (без реального + зависания) — ошибочно уводил бы в recovery через ~4.5 с в штатном ожидании; в стенде `test_stub` + health-mark вызывался безусловно до проверки `HANG_MODE`, из-за чего счётчик никогда не + накапливался выше 1 (фолбэк не срабатывал); в самом чек-листе Фазы 6 предписывался файл для + чужого слота при проверке recovery-установки; `attempt_boot()` не инкрементировал счётчик перед + первым прыжком в свежеустановленный recovery-образ (симметрия с обычным путём). +- **Фаза 4** (3 бага): AXI-QoS регистры (NIC-301 GPV) валили C-код фолтом — не покрыты + `board_mpu_init()`, DCD успевал их записать до включения MPU, C-порт — нет; результаты + smoke-теста терялись (шлются один раз сразу после `init()`, хост не успевает открыть порт) — + кэширование + переспрос по команде; оценка длительности SDRAM-теста в комментарии оригинала + завышена ~в 6 раз (реальный прогон ~4.2 с, не ~30 с). +- **Фаза 5** (2 бага в `service-tui`, до публикации): production шил bootloader и (будущий) app по + одному адресу `FLASH_BASE` — второй шаг затёр бы первый (наследие до-bootloader архитектуры, для + Direct-XIP неверно); production мог тихо взять unsigned Debug-образ bootloader через + `FIRMWARE_BUILD_TYPE` (переменная предназначена только для firmware_test, дефолт `Debug`) — теперь + Release резолвится жёстко. + +### Тесты + +- Host-тесты выросли с 13 (Фаза 2) до 16 (Фазы 3/6: `update_policy`, `slot_version`, `recovery`) — + зелёные, Debug и Release, обе платформы (macOS + devcontainer Linux). +- `tools/service_tui`: 68 → 76 тестов (Фаза 5) — Тир-0 readback (`test_flash_backend.py`) + новый + `test_bootloader_client.py`. +- Полный аппаратный чек-лист пройден на каждой фазе (Фазы 2–6); детали были в удалённых + `HARDWARE_VERIFICATION_*.md`/`DEBUG_LOG_*.md` (см. git-история, "Удалено" ниже). + +### CI + +- `.github/workflows/release.yml`: новый job `publish-bootloader` (тег `bootloader-v*`), общий job + `firmware` расширен на сборку Release HAB bootloader; `service-tui-{macos,windows}` теперь + докачивают `bootloader_hab.bin` в `build/Release/` перед упаковкой — без этого `just + host::package-tui` падал бы с новым гвардом (см. "Изменено"). +- CI-баг `fih_panic_loop`/x86_64 (Фаза 3, см. "Исправлено") — точечный патч вендоренного + `fault_injection_hardening.c` под `#if defined(__arm__)`. + +### Документация + +- `docs/bootloader/HAB_GUIDE.md` — новый §5.1 (разбор команд CSF-секции `nxpimage`, NOCAK vs полная + SRK-иерархия). +- `firmware/bootloader/SIGNING_CEREMONY.md` — новый план настоящей production-подписи (HAB + SRK-церемония + MCUboot production-ключ), на будущее. +- `docs/DEV_ARCH.md` (корневой) — исправлена фактическая ошибка (bootloader описывался как + «копирование в ITCM», реально XIP без ITCM/DCD) и устаревший путь `tools/production/`. +- `tools/service_tui/docs/DEV_ARCH.md` → переименован в `tools/service_tui/docs/ARCHITECTURE.md` + (коллизия имени с корневым `docs/DEV_ARCH.md`); новый §16 (Тир-0/Тир-1); синхронизирован со всеми + изменениями Фазы 5; починены 10 битых/несогласованных ссылок на файл в трёх других README. +- `docs/CI_WORKFLOW.md` — синхронизирован с `bootloader-v*` (диаграмма, триггеры, таблица job'ов). + +### Удалено + +- `firmware/bootloader/PLAN.md`, `DEBUG_LOG_PHASE2.md`, `DEBUG_LOG_PHASE3_SD.md`, + `test_stub/HARDWARE_VERIFICATION_{PHASE2,PHASE3,PHASE6,LED_PATTERNS}.md`, `docs/CI_PLAN.md` — + планирующие/трекинговые документы и чек-листы, отработавшие своё после завершения всех фаз; + фактическое содержание либо перенесено в постоянные документы (`README.md`, + `docs/bootloader/HAB_GUIDE.md`, `docs/CI_WORKFLOW.md`), либо остаётся доступным в git-истории. Тот + же паттерн, что уже применялся к `FIRST_RELEASE_PLAN.md`/`RELEASE_ROADMAP.md`/`just/ci_workflow.md` + при предыдущем релизе (см. запись ниже). + +## [2026-07-07 .. 2026-07-13] — Первый релиз: `tui-v0.2.0`/`firmware-v0.1.2`, точечный `tui-v0.2.1` + +Диапазон: `8869b3c8..d9fb813b` +Сравнение: + +### Кратко + +- Первый тег-релиз проекта: `firmware-v0.1.2` (firmware_test HAB Debug) и `tui-v0.2.0` (service-tui + PyInstaller-бандл, macOS + Windows) — итог ветки `feature-tui-monolith` (см. запись ниже, закрыта + этим же релизом). +- Директория service-tui переименована `tools/production/` → `tools/service_tui/`. +- Точечный релиз `tui-v0.2.1` (отдельная ветка `service-tui-fixes`) — найден и исправлен полевой баг + записи во Flash, воспроизводившийся на случайном подмножестве плат. + +### Изменено + +- `tools/production/` → `tools/service_tui/` (директория и все внутренние пути/ссылки). +- Из репозитория убран ранее случайно закоммиченный `dist/` (собранные PyInstaller-бандлы) — + добавлен `.gitignore`. + +### Исправлено + +- **QE-бит (Winbond) не выставлялся при auto-config Flashloader — ~50/500 плат в поле падали на + ЛЮБОЙ flash-операции.** Option word `0xC0000007` (со старта проекта, унаследован + `flash_backend.py`/`flash_usb.py`) не включает Quad Enable; часть партий W25Q128 приходит с завода + с QE=0, из-за чего чип остаётся в SPI-режиме при LUT, настроенных на quad-команды → + `status 20106 FlexSPINOR: Command Failure` на любой команде. Две промежуточные гипотезы (порядок + commit-FCB/erase; маргинальный электрический контакт) проверены на живом железе и опровергнуты. + Причина найдена пересчётом (не «на глаз») десятичного option word из логов NXP MCUBootUtility: + `0xC0000207`. QE энергонезависимый — после одной корректной установки (в т.ч. случайно, через + сторонний инструмент) плата «чинится» навсегда, что и маскировало баг как нестабильный. +- M5StampPLC: два раунда фиксов детекта порта и CAN-обмена в `service-tui`. + +### Удалено + +- Планирующие документы, отработавшие своё к моменту релиза — `FIRST_RELEASE_PLAN.md`, + `RELEASE_ROADMAP.md`, `just/ci_workflow.md`, `tools/production/docs/MONOLITH_APP_PLAN.md`. + Содержание перенесено в постоянные `README.md`/`docs/DEV_ARCH.md` (тогда ещё под именем + `tools/production/`). + +## [2026-07-07] — service-tui: монолитный spsdk-бэкенд, устойчивость к обрыву USB, упаковка PyInstaller + +Диапазон: `1801f1beb959d610d31ee3dcd1f91046953117d4..22c40779ef0ec9911031d7a5272c4611b596d3e8` (мёрж в `dev` — `b4c664fe121226c4231675a150fba809e73b21d6`) Сравнение: -> Ветка `feature-tui-monolith` (от `dev`, поверх мержа `feature-tui-python`). +> Ветка `feature-tui-monolith` (от `dev`, поверх мержа `feature-tui-python`). Смёржено в `dev` и +> выпущено как часть первого релиза (`tui-v0.2.0`/`firmware-v0.1.2`) — см. запись выше. > **Полностью заменяет предыдущую версию этой записи**: прошивка сторонних > бинарников через subprocess (`tools/host/flash_usb.py --fcb-path` + > `nxpimage`) была реализацией на момент Фазы 0/раннего мержа и с тех пор diff --git a/README.md b/README.md index dea2fba..ac32fca 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,21 @@ ## Firmware-проекты -| Проект | Путь | Описание | -| ------------------- | ---------------------- | -------------------------------------------------------------------------------------- | -| Тестовая прошивка (реализована) | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS | -| Загрузчик (запланирован) | `firmware/bootloader/` | A/B обновление через uSD. Обновляется только через USB ROM + blhost / SWD | -| Production прошивка (запланирован) | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком | +| Проект | Путь | Описание | +| ----------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Тестовая прошивка | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS [README](firmware/test/README.md) | +| Загрузчик | `firmware/bootloader/` | A/Б обновление через uSD (MCUboot, Direct-XIP), recovery при зависании образа. Обновляется только через USB ROM + blhost / SWD [README](firmware/bootloader/README.md) | +| Production прошивка (запланирована) | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком | --- ## Инструменты (`tools/`) -| Инструмент | Путь | Назначение | -| ------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------- | +| Инструмент | Путь | Назначение | +| ------------------ | -------------------- | ---------------------------------------------------------------------------------------------- | | Сервисный TUI | `tools/service_tui/` | Диагностика и прошивка готовых плат сервисным инженером [README](tools/service_tui/README.md) | -| Прошивка (dev-CLI) | `tools/host/` | USB SDP / SWD прошивка при разработке [README](tools/host/README.md) | -| HIL-тесты | `tools/hil/` | pytest-окружение аппаратных тестов [README](tools/hil/README.md) | +| Прошивка (dev-CLI) | `tools/host/` | USB SDP / SWD прошивка при разработке [README](tools/host/README.md) | +| HIL-тесты | `tools/hil/` | pytest-окружение аппаратных тестов [README](tools/hil/README.md) | --- diff --git a/docs/CI_PLAN.md b/docs/CI_PLAN.md deleted file mode 100644 index 47bc228..0000000 --- a/docs/CI_PLAN.md +++ /dev/null @@ -1,84 +0,0 @@ -# Текущее состояние CI/CD workflow для `tft_manufacture_test` - -## Обзор - -В репозитории настроен рабочий GitHub Actions pipeline, который успешно запускается на событиях `push`, `pull_request` и при ручном запуске через `workflow_dispatch`. Пайплайн специально привязан к реальному окружению разработки проекта: сборка и тесты выполняются внутри того же devcontainer-образа, который описан в `.devcontainer/Dockerfile`, а не в вручную собранной среде на `ubuntu-latest`. - -Такой подход уже устранил основные проблемы, которые проявились при первичном поднятии CI: отсутствие ARM toolchain, отсутствие `ninja`, установка неправильного `just` и несовместимость прав доступа при работе с bind-mounted workspace внутри Docker. - -## Текущая архитектура - -Workflow разделён на две job’ы: `build` и `test`.[cite:156] Такое разделение делает пайплайн проще для сопровождения, позволяет отдельно анализировать результаты стадии сборки и создаёт хороший фундамент для следующих этапов — `lint`, coverage, release packaging и аппаратных проверок. - -Среда выполнения строится из Dockerfile devcontainer-а проекта, в котором уже определены ARM GCC toolchain в `/opt/arm-toolchain`, обновлённый `PATH`, а также установлены `cmake`, `ninja-build`, `clang-17`, `uv` и `just`.[cite:125] Поскольку все ключевые зависимости уже зафиксированы именно там, использование этого же образа в CI делает поведение раннера максимально близким к локальной разработке.[cite:125] - -## Как работает workflow - -Workflow реагирует на три типа событий: `push`, `pull_request` и `workflow_dispatch`. Это даёт удобный баланс между автоматической проверкой обычных коммитов и возможностью вручную перезапускать pipeline для отладки инфраструктурных или нестабильных падений без обязательного нового изменения в коде. -Внутри job используется Docker Buildx и `docker/build-push-action`, а кэширование слоёв контейнера подключено через backend GitHub Actions cache с помощью `cache-from: type=gha` и `cache-to: type=gha`. За счёт этого повторные прогоны не пересобирают devcontainer с нуля, а переиспользуют уже собранные Docker-слои, что заметно ускоряет пайплайн после первого успешного заполнения кэша. - -## Что делает job `build` - -Job `build` выполняет checkout репозитория, инициализирует Buildx, собирает devcontainer image с поддержкой кэша, проверяет версии инструментов внутри контейнера, синхронизирует Python tooling в `tools/host` через `uv sync`, а затем запускает `just ci::build` внутри контейнера. После успешной сборки workflow выгружает директорию `build/` как GitHub artifact, чтобы результаты можно было сохранить и использовать на следующих стадиях. - -Важная техническая деталь — команды внутри контейнера запускаются с `--user root`.Это требуется из-за того, что `GITHUB_WORKSPACE` подключается в контейнер как bind mount, а в GitHub Actions non-root пользователь внутри Docker часто не получает права на запись в такую директорию; ранее это как раз ломало создание `.venv` во время `uv sync`. - -## Что делает job `test` - -Job `test` зависит от `build`, скачивает artifact с директорией `build/`, заново поднимает тот же devcontainer image с использованием cached layers, синхронизирует `tools/host` и запускает `just ci::test` внутри контейнера. На практике это означает, что host unit-тесты работают в той же программной среде, что и стадия сборки, но при этом выделены в отдельный CI-этап. - -Так как GitHub-hosted runner’ы эфемерны, сам Docker image не передаётся напрямую между job’ами.[cite:143] Поэтому обмен между `build` и `test` организован двумя способами: ускорение повторной сборки образа идёт через Docker layer cache, а результаты проекта передаются через GitHub artifacts. - -## Почему эта схема хорошо подходит проекту - -Этот репозиторий нельзя считать обычным desktop C-проектом: он завязан на фиксированное расположение embedded toolchain и на специально подготовленный devcontainer.[cite:125] Ранние попытки выполнять pipeline прямо на runner’е падали, потому что проект ожидал наличие `/opt/arm-toolchain`, установленный `Ninja` и современный бинарник `just`, который понимает атрибуты вроде `[doc(...)]`. - -Перенос CI внутрь devcontainer image устраняет этот класс расхождений и делает Dockerfile единым источником истины для окружения, версий и путей.[cite:125] Это упрощает дальнейшее сопровождение: при изменении инструментария достаточно обновить Dockerfile, и эти же изменения автоматически начнут действовать как локально, так и в CI.[cite:125] - -## Чего workflow пока не делает - -Текущий pipeline пока не включает обязательную стадию `lint` и статический анализ, потому что в `just/ci.just` для `lint` пока ещё оставлена заглушка, а не полноценный вызов `clang-format` и `clang-tidy`. Он также пока не формирует release/HAB artifacts в CI, хотя в репозитории уже есть соответствующие рецепты `just ci::release` и связанные сборочные шаги. - -Также pipeline пока не запускает HIL-сценарии. Это ожидаемо и правильно для текущего этапа: hardware-in-the-loop проверки требуют физического оборудования и в дальнейшем должны выполняться отдельно на self-hosted runner рядом с bench-стендом, а не на GitHub-hosted машинах. - -## Сильные стороны текущего решения - -У текущей реализации уже есть несколько сильных сторон: - -- Она воспроизводима, потому что сборка и тесты выполняются в том же образе, что и локальная разработка. -- Она ускоряется на повторных прогонах за счёт Docker layer caching через GitHub Actions cache backend. -- Она модульна, потому что `build` и `test` вынесены в отдельные job’ы, связанные артефактами. -- Она удобна для отладки, потому что build outputs сохраняются как artifacts, а workflow можно запускать вручную через `workflow_dispatch`. -- Она хорошо вписана в структуру проекта, потому что использует уже существующие `just`-точки входа, а не дублирует build-логику в YAML. - -## Текущие ограничения - -Главное ограничение сейчас состоит в том, что pipeline проверяет собираемость и host unit-тесты, но ещё не закрывает style gate, static analysis, coverage и release packaging. Второе ограничение — Docker image пересобирается в каждой job, поэтому даже при наличии кэша остаётся неизбежный накладной расход по времени по сравнению с вариантом, где используется заранее опубликованный образ из registry.[cite:143] - -Есть и архитектурное ограничение GitHub-hosted runner’ов для аппаратной части.[cite:127] Прошивка через USB, pyOCD-сценарии и управление стендом должны в будущем быть вынесены в отдельную hardware lane на self-hosted runner. - -## Рекомендуемые следующие шаги - -### Шаг 1 — добавить `lint` job - -Самое логичное следующее улучшение — реализовать полноценную стадию `lint` в `just/ci.just` и подключить отдельную job в workflow. В эту стадию стоит включить `clang-format --dry-run --Werror`, `clang-tidy` и необходимые исключения для generated-кода или vendor-зависимостей, чтобы избежать лишнего шума в CI. - -### Шаг 2 — добавить coverage - -После стабилизации `lint` полезно подключить экспорт coverage для host-тестов. В `ci.just` уже существует закрытый рецепт `_coverage`, и его можно развить до генерации XML-отчёта, выгрузки артефактов и последующей интеграции с внешним coverage-сервисом, если это будет нужно. - -### Шаг 3 — выделить release workflow - -Release packaging лучше оформлять отдельным workflow или отдельной gated job, запускаемой только по тегам, на `main` или вручную через `workflow_dispatch`. Это позволит не замедлять обычный PR-цикл, но при этом использовать `just ci::release` и публикацию HAB-артефактов тогда, когда это действительно нужно. - -### Шаг 4 — публиковать devcontainer image в GHCR - -Следующий сильный шаг по оптимизации — публиковать devcontainer image в GHCR и затем запускать CI уже на базе заранее собранного образа, а не пересобирать его в каждой job.Это ещё сильнее сократит время старта pipeline и сделает масштабирование на `lint`, `coverage` и `release` заметно проще. - -### Шаг 5 — добавить self-hosted HIL lane - -Финальное крупное направление развития — выделенный аппаратный workflow на self-hosted runner с доступом к MCU-Link, target board и M5StampPLC. Такую lane лучше запускать вручную, по расписанию или по label-триггеру, а не делать обязательной для каждого PR, поскольку аппаратные проверки медленнее, менее стабильны и по природе отличаются от быстрых software regression checks. - -## Целевое состояние - -Зрелая версия этого CI/CD контура, вероятно, будет состоять из четырёх независимых линий: быстрый PR-pipeline (`build`, `test`, `lint`), optional coverage reporting, отдельный release workflow и отдельный self-hosted HIL pipeline.[cite:156] Такая структура сохранит короткий feedback loop для обычной разработки и одновременно покроет полный жизненный цикл embedded-проекта: от изменений в исходниках до production artifacts и аппаратной валидации на стенде. diff --git a/docs/CI_WORKFLOW.md b/docs/CI_WORKFLOW.md index 48a1ecd..ea0e701 100644 --- a/docs/CI_WORKFLOW.md +++ b/docs/CI_WORKFLOW.md @@ -3,9 +3,8 @@ > Проект: TFT Firmware (MIMXRT1052CVJ5B) > Документ описывает схему и принцип работы двух GitHub Actions workflow в > репозитории: `ci.yml` (обычный PR/push-цикл) и `release.yml` (публикация -> релизных бинарников по тегу). Для истории решений и roadmap развития -> `ci.yml` — см. `just/ci_workflow.md`; этот документ — техническая справка -> «как оно работает сейчас», а не хронология. +> релизных бинарников по тегу). Это техническая справка «как оно работает +> сейчас», а не хронология решений. --- @@ -13,15 +12,14 @@ | | `ci.yml` | `release.yml` | | --- | --- | --- | -| Когда запускается | `push` в `dev`/`main`, любой `pull_request`, `workflow_dispatch` | `push` тега `tui-v*` / `firmware-v*`, `workflow_dispatch` | +| Когда запускается | `push` в `dev`/`main`, любой `pull_request`, `workflow_dispatch` | `push` тега `tui-v*` / `firmware-v*` / `bootloader-v*`, `workflow_dispatch` | | Что проверяет | Собирается ли проект и проходят ли host-тесты | Собираются ли и публикуются ли релизные бинарники | | Публикует что-то наружу? | Нет — только артефакты прогона (для отладки) | Да — GitHub Release с реальными asset'ами (только по тегу) | | Раннеры | `ubuntu-latest` (оба job'а) | `ubuntu-latest` + `macos-latest` + `windows-latest` | -Они намеренно не смешаны в один файл (см. `just/ci_workflow.md`, «Шаг 3 — -выделить release workflow»): PR-цикл должен оставаться быстрым и не зависеть -от кросс-платформенной упаковки `service-tui`, а релизная публикация не -должна гонять host-тесты повторно на каждый push в PR. +Они намеренно не смешаны в один файл: PR-цикл должен оставаться быстрым и не +зависеть от кросс-платформенной упаковки `service-tui`, а релизная +публикация не должна гонять host-тесты повторно на каждый push в PR. --- @@ -57,8 +55,7 @@ bash -lc '...'` — `--user root` обязателен, иначе non-root по Чего `ci.yml` **не делает**: lint (заглушка в `just ci::lint`), coverage, сборку/упаковку `service-tui`, HIL-тесты (нужно физическое железо — -самостоятельная задача для self-hosted раннера). См. `just/ci_workflow.md` -для планов по каждому из этих пунктов. +самостоятельная задача для self-hosted раннера). См. §5 ниже. --- @@ -66,19 +63,21 @@ bash -lc '...'` — `--user root` обязателен, иначе non-root по ### 3.1 Схема тегов -Firmware (`firmware_test`) и `service-tui` версионируются и релизятся -**независимо** (`FIRST_RELEASE_PLAN.md`, Шаг 2.1) — два разных паттерна -тега запускают два разных сценария внутри одного workflow-файла: +Firmware (`firmware_test`), bootloader и `service-tui` версионируются и +релизятся **независимо** — три разных паттерна тега запускают три разных +сценария внутри одного workflow-файла: ```mermaid flowchart TD push_fw["push tag\nfirmware-vX.Y.Z"] --> firmware + push_bl["push tag\nbootloader-vX.Y.Z"] --> firmware push_tui["push tag\ntui-vX.Y.Z"] --> firmware - dispatch["workflow_dispatch\n(release_type: tui | firmware)"] --> firmware + dispatch["workflow_dispatch\n(release_type: tui | firmware | bootloader)"] --> firmware - firmware["firmware\n(ubuntu-latest, devcontainer)\njust build::hab-firmware-test-debug"] + firmware["firmware\n(ubuntu-latest, devcontainer)\nhab-firmware-test-debug +\nhab-bootloader-release"] firmware -->|"тег firmware-v*"| publishFw["publish-firmware\ngh release create\n(HAB Debug)"] + firmware -->|"тег bootloader-v*"| publishBl["publish-bootloader\ngh release create\n(HAB Release, тестовый ключ)"] firmware -->|"тег tui-v* ИЛИ\ndispatch(tui)"| macos["service-tui-macos\njust host::package-tui"] firmware -->|"тег tui-v* ИЛИ\ndispatch(tui)"| windows["service-tui-windows\njust host::package-tui"] @@ -87,22 +86,24 @@ flowchart TD windows -->|"тег tui-v*"| publishTui ``` -Ключевое архитектурное решение: **HAB-образ `firmware_test`, который -вшивается внутрь TUI-бандла, всегда собирается заново из текущего HEAD** -джобой `firmware` — а не скачивается из последнего опубликованного -`firmware-v*` релиза. Поэтому job `firmware` выполняется **при любом -триггере**, без условия — она нужна и для отдельного firmware-релиза, и -как зависимость для упаковки TUI. +Ключевое архитектурное решение: **HAB-образы `firmware_test` и `bootloader`, +которые вшиваются внутрь TUI-бандла, всегда собираются заново из текущего +HEAD** джобой `firmware` — а не скачиваются из последних опубликованных +`firmware-v*`/`bootloader-v*` релизов. Поэтому job `firmware` выполняется +**при любом триггере**, без условия — она нужна и для отдельных релизов +firmware_test/bootloader, и как общая зависимость для упаковки TUI (см. +Фазу 5, `firmware/bootloader/PLAN.md` — до неё `firmware` собирала только +firmware_test, и TUI-бандл молча уходил без образа bootloader). ### 3.2 Триггеры ```yaml on: push: - tags: ["tui-v*", "firmware-v*"] + tags: ["tui-v*", "firmware-v*", "bootloader-v*"] workflow_dispatch: inputs: - release_type: {type: choice, options: [tui, firmware], default: tui} + release_type: {type: choice, options: [tui, firmware, bootloader], default: tui} ``` `workflow_dispatch` — «сухой прогон» без публикации: собирает всё @@ -125,15 +126,19 @@ job-level `if:`, использовать именно эту форму. | Job | Раннер | Когда выполняется | Что делает | | --- | --- | --- | --- | -| `firmware` | `ubuntu-latest`, devcontainer (тот же подход и кэш, что в `ci.yml`) | всегда | сверяет тег `firmware-v*` с `VERSION` в `firmware/test/CMakeLists.txt` (если применимо); `just build::hab-firmware-test-debug`; артефакт `firmware-hab-debug` | +| `firmware` | `ubuntu-latest`, devcontainer (тот же подход и кэш, что в `ci.yml`) | всегда | сверяет тег `firmware-v*` с `VERSION` в `firmware/test/CMakeLists.txt` (если применимо); `just build::hab-firmware-test-debug` → артефакт `firmware-hab-debug`; сверяет тег `bootloader-v*` с `VERSION` в `firmware/bootloader/CMakeLists.txt` (если применимо); `just build::hab-bootloader-release` → артефакт `bootloader-hab-release` | | `publish-firmware` | `ubuntu-latest` | только push тега `firmware-v*` | скачивает `firmware-hab-debug`; `gh release create firmware-vX.Y.Z firmware_test_hab.bin` — standalone-релиз для `tools/host/flash_usb.py`, без TUI | -| `service-tui-macos` / `service-tui-windows` | `macos-latest` / `windows-latest` | push тега `tui-v*` ИЛИ `workflow_dispatch` с `release_type=tui` | `astral-sh/setup-uv` + `extractions/setup-just` (на раннерах нет `uv`/`just` из коробки); скачивает `firmware-hab-debug` в `build/Debug/`; сверяет тег `tui-v*` с `version` в `pyproject.toml` (если применимо); `just host::service-setup` + `just host::package-tui`; архивирует `dist/service-tui-vX.Y.Z-/` в zip (`zip -r` на macOS, `Compress-Archive` на Windows); артефакт `service-tui-macos`/`service-tui-windows` | +| `publish-bootloader` | `ubuntu-latest` | только push тега `bootloader-v*` | скачивает `bootloader-hab-release`; `gh release create bootloader-vX.Y.Z bootloader_hab.bin` — standalone-релиз (USB ROM/SWD, без TUI); `--notes` явно предупреждает, что HAB-подпись тестовым ключом, не production (см. `firmware/bootloader/SIGNING_CEREMONY.md`) | +| `service-tui-macos` / `service-tui-windows` | `macos-latest` / `windows-latest` | push тега `tui-v*` ИЛИ `workflow_dispatch` с `release_type=tui` | `astral-sh/setup-uv` + `extractions/setup-just` (на раннерах нет `uv`/`just` из коробки); скачивает `firmware-hab-debug` в `build/Debug/` и `bootloader-hab-release` в `build/Release/`; сверяет тег `tui-v*` с `version` в `pyproject.toml` (если применимо); `just host::service-setup` + `just host::package-tui` (падает явно, если `build/Release/bootloader_hab.bin` не найден — production-функция TUI жёстко требует именно этот файл); архивирует `dist/service-tui-vX.Y.Z-/` в zip (`zip -r` на macOS, `Compress-Archive` на Windows); артефакт `service-tui-macos`/`service-tui-windows` | | `publish-tui` | `ubuntu-latest`, `needs: [service-tui-macos, service-tui-windows]` | только push тега `tui-v*` | скачивает оба zip; `gh release create tui-vX.Y.Z *.zip` | -**Только Debug HAB** идёт в релиз (`FIRST_RELEASE_PLAN.md`, Шаг 1.5) — -Release-сборка `firmware_test` нестабильна (FCB/clock), поэтому `firmware` -джоба собирает только `hab-firmware-test-debug`, не полный -`hab-all-release`. +**Debug HAB для firmware_test, Release HAB для bootloader** — не единое +правило «всегда Debug». Release-сборка `firmware_test` нестабильна +(FCB/clock), поэтому `firmware`-джоба собирает `hab-firmware-test-debug`, +не полный `hab-all-release`. Bootloader — наоборот: production-путь (Фаза 5, +`firmware/bootloader/PLAN.md`) жёстко требует **Release**, подписанный +(`flags=0x08`) — Debug-конфиг bootloader остаётся unsigned и используется +только для локальной отладки, в релиз/TUI-бандл не попадает. **Сверка версии тег↔файл** — маленький, но важный guard в обеих ветках (`firmware`/`service-tui-*`): если версия в теге не совпадает с версией в @@ -152,6 +157,7 @@ Git Bash на Windows. ```bash gh workflow run release.yml --ref dev -f release_type=tui gh workflow run release.yml --ref dev -f release_type=firmware +gh workflow run release.yml --ref dev -f release_type=bootloader ``` или через веб-интерфейс: Actions → **Release** → **Run workflow** → выбрать @@ -180,10 +186,13 @@ branch и `release_type`. Джобы `publish-*` в этом сценарии п - **Self-hosted HIL-раннер.** Ни один из двух workflow не может задетектировать SDP на живой плате или прогнать деструктивные сценарии (обрыв USB) — GitHub-hosted раннеры не видят реальное железо. Это ручной - шаг перед каждым релизом (`FIRST_RELEASE_PLAN.md`, Шаг 4), пока не - поднят self-hosted lane (`just/ci_workflow.md`, «Шаг 5»). -- **`lint`/`coverage`** не подключены ни в `ci.yml`, ни в `release.yml` — - см. `just/ci_workflow.md`, «Рекомендуемые следующие шаги» (Шаги 1–2). + шаг перед каждым релизом, пока не поднят self-hosted lane. +- **`lint`/`coverage`** не подключены ни в `ci.yml`, ни в `release.yml`. - **Публикация devcontainer image в GHCR** — образ пересобирается в каждой job'е каждого workflow (пусть и с layer-кэшем); заранее опубликованный - образ сократил бы время старта ещё сильнее (`just/ci_workflow.md`, «Шаг 4»). + образ сократил бы время старта ещё сильнее. +- **`bootloader-v*`/`tui-v*` релизы несут HAB-образ, подписанный ТЕСТОВЫМ + ключом** (`tools/host/hab/keys/`, HAB Open, схема NOCAK) — не production. + Реальная SRK-церемония описана в + `firmware/bootloader/SIGNING_CEREMONY.md`, в CI пока не встроена (сама + церемония — не автоматизируемый процесс, см. документ). diff --git a/docs/DEV_ARCH.md b/docs/DEV_ARCH.md index dceb0c5..71b45cb 100644 --- a/docs/DEV_ARCH.md +++ b/docs/DEV_ARCH.md @@ -68,7 +68,7 @@ graph TB end ``` ---- +--- ## 3. Что устанавливается и где @@ -198,9 +198,9 @@ flowchart LR │ │ │ dcd.bin, ivt_flashloader.bin │ │ └── uv.lock │ │ -│ ├── production/ ← service-tui: TUI сервисного инженера (Textual) +│ ├── service_tui/ ← service-tui: TUI сервисного инженера (Textual) │ │ прошивка/диагностика готовых плат, см. -│ │ tools/service_tui/README.md + DEV_ARCH.md +│ │ tools/service_tui/README.md │ │ │ └── hil/ ← HIL pytest-окружение │ ├── conftest.py ← фикстуры: m5, loaded_, uart_ @@ -356,8 +356,8 @@ buildPresets (HIL): | Прошивка | Стратегия | Инструмент загрузки | | ---------------------------- | ---------------------------------- | ------------------- | | `firmware_test` | XIP из Flash (`flexspi_nor.ld`) | SPSDK → Flash | -| `bootloader` | Копирование в ITCM | SPSDK → Flash | -| `tft_app` | XIP + буферы в SDRAM | SPSDK → Flash | +| `bootloader` | XIP из Flash, без ITCM/DCD (не трогает SDRAM) — выбирает и запускает `tft_app` из слота (MCUboot Direct-XIP) | SPSDK → Flash | +| `tft_app` | XIP из своего слота (Direct-XIP, два слота A/Б) + буферы в SDRAM (SEMC поднимает сама) | SPSDK → Flash | | HIL target (`tests/target/`) | Исполнение из ITCM/DTCM (`ram.ld`) | pyOCD → RAM | **HIL boot-стратегия:** pyOCD настраивает FLEXRAM (128 KB ITCM + 128 KB DTCM + 256 KB OCRAM), записывает PT_LOAD сегменты ELF по физическим адресам, устанавливает SP/PC из таблицы векторов и запускает выполнение. Flash не используется — прошивка исчезает при отключении питания. diff --git a/docs/HOW_TO_FLASH.md b/docs/HOW_TO_FLASH.md index d43b790..58d2f32 100644 --- a/docs/HOW_TO_FLASH.md +++ b/docs/HOW_TO_FLASH.md @@ -93,7 +93,7 @@ in-process через Python API `spsdk` (`app/flash_backend.py`: `HabImage`, `configure-memory 0xF000000F`) — auto-config для 4-байтной адресации не проверялся, решили на него не полагаться -Подробности конвейера — в [tools/service_tui/docs/DEV_ARCH.md](../tools/service_tui/docs/DEV_ARCH.md), +Подробности конвейера — в [tools/service_tui/docs/ARCHITECTURE.md](../tools/service_tui/docs/ARCHITECTURE.md), §8. Штатный путь (`--firmware`, три сборки этого репозитория, что через `just host::flash`, что через `service-tui`) не меняется и по-прежнему использует auto-config Flashloader, как описано в 1.4. diff --git a/docs/bootloader/HAB_GUIDE.md b/docs/bootloader/HAB_GUIDE.md index fcb0702..5482203 100644 --- a/docs/bootloader/HAB_GUIDE.md +++ b/docs/bootloader/HAB_GUIDE.md @@ -84,9 +84,9 @@ typedef struct { DCD содержит последовательность команд двух типов: -| Команда | Тег | Назначение | -|---|---|---| -| `Write Data` | `0xCC` | Записать значение по адресу | +| Команда | Тег | Назначение | +| ---------------- | ------ | -------------------------------- | +| `Write Data` | `0xCC` | Записать значение по адресу | | `Check Bits Set` | `0xCF` | Ждать пока бит станет `1` (poll) | Формат бинарника: `Tag(1) | Length(2 BE) | Parameter(1) | данные`. @@ -105,11 +105,11 @@ DCD содержит последовательность команд двух ### 4.1 По типу образа -| Режим | `flags` | CSF | Применение | -|---|---|---|---| -| Unsigned | `0x00` | отсутствует | разработка, отладка | -| Signed | `0x08` | RSA/ECDSA подпись | производство | -| Signed + Encrypted | `0x0C` | подпись + шифрование кода | максимальная защита | +| Режим | `flags` | CSF | Применение | +| ------------------ | ------- | ------------------------- | ------------------- | +| Unsigned | `0x00` | отсутствует | разработка, отладка | +| Signed | `0x08` | RSA/ECDSA подпись | производство | +| Signed + Encrypted | `0x0C` | подпись + шифрование кода | максимальная защита | ### 4.2 По состоянию чипа (OTP fuse) @@ -150,6 +150,42 @@ BootROM проверяет подпись, но **игнорирует ошиб → прыгает на entry point ``` +### 5.1 Как это выглядит в конфиге nxpimage (`sections:` в hab_*.yaml) + +Схема выше — идеальная production-картина с SRK-таблицей на 4 ключа. На практике `nxpimage hab export` +собирает CSF из списка команд в `sections:` конфига — прямой аналог CSF-файла из NXP CST (Code Signing +Tool, см. AN12263 в §10), только в YAML вместо самодельного текстового формата CST. + +В `firmware/bootloader` сейчас (тестовый ключ, до SRK-церемонии — см. §8) используется упрощённая +схема — **HAB4 NOCAK** («No CA Key», fast authentication): один ключ вместо иерархии SRK→CSFK/IMG. +Пример — [tools/host/hab/hab_bootloader_release.yaml](../../tools/host/hab/hab_bootloader_release.yaml): + +```yaml +sections: + - Header: {...} # версия HAB, hash-алгоритм, формат сертификата/подписи — заголовок CSF + - InstallNOCAK: {...} # ставит ОДИН сертификат в слот 0 key store вместо SRK-таблицы + - AuthenticateCSF: {...} # подписывает сам CSF-контейнер (иначе BootROM не станет читать остальные команды) + - AuthenticateData: {...} # подписывает содержимое образа (IVT+BDT+код) — это и проверяет BootROM перед прыжком +``` + +| Команда | Что делает | Ключевые поля | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `Header` | Обязательна, идёт первой. Задаёт версию HAB (`4.2` для RT105x), алгоритм хэша (`sha256`), формат сертификата (`x509`) и формат подписи (`CMS`) — умолчания для всех следующих команд. | — | +| `InstallNOCAK` | Устанавливает публичный сертификат (`InstallNOCAK_File`) в слот 0 внутреннего key store HAB. В полной production-схеме (диаграмма выше) это делают `InstallSRK`+`InstallCSFK` — раздельные ключи с возможностью ревокации по отдельности. NOCAK — сознательное упрощение, годится только с тестовым ключом. | `InstallNOCAK_File` — путь к `.pem`-сертификату | +| `AuthenticateCSF` | Подписывает сам CSF-контейнер закрытым ключом — иначе ROM не станет доверять остальным командам после этой точки. Подпись создаётся на хосте во время сборки (`nxpimage`), не на чипе. | `Signer` — путь к приватному ключу | +| `AuthenticateData` | Подписывает реальные данные образа (IVT, BDT, код) — то, что BootROM хэширует и сверяет с подписью перед прыжком на `entry point`. `AuthenticateData_VerificationIndex` должен совпадать со слотом установленного ключа (`0` для NOCAK). | `AuthenticateData_VerificationIndex`, `Signer` | + +Ключи для тестовой подписи — [tools/host/hab/keys/](../../tools/host/hab/keys/) (пояснение там же в +README). **Для реальной production-подписи** `flags=0x08` остаётся, но `InstallNOCAK` меняется на +полную `InstallSRK`+`InstallCSFK` (+`InstallKey` под отдельный IMG-ключ) цепочку с настоящими SRK +table/fuse-файлами, сгенерированными в рамках SRK-церемонии (§8) — сама YAML-механика (`nxpimage hab +export`, `flags`, `AuthenticateData`) не меняется, меняются только сертификаты и добавляются команды +установки промежуточных ключей. + +Проверить, что CSF реально встроился в готовый образ: `just build::hab-verify ` +(обёртка над `nxpimage hab parse`) — для подписанного образа поле `csf` в IVT ненулевое и рядом +появляется отдельный `csf.bin`; для unsigned (`flags=0x00`) оба отсутствуют. + --- ## 6. Почему Unsigned-образ требует HAB-контейнер @@ -170,7 +206,7 @@ BootROM проверяет подпись, но **игнорирует ошиб BootROM в режиме SDP умеет только писать в RAM и прыгать. Для записи во Flash необходим **Flashloader** — специальная программа от NXP. -``` +```bash Плата в SDP режиме (BOOT_MOD_1 = 3V3) │ │ sdphost -u 0x1FC9,0x0130 @@ -192,12 +228,12 @@ BootROM в режиме SDP умеет только писать в RAM и пр ## 8. Жизненный цикл для проекта TFT -| Стадия | Режим HAB | Подпись | Fuse | -|---|---|---|---| -| Разработка | Open | Unsigned (`0x00`) | не трогаем | -| Входной контроль | Open | Unsigned (`0x00`) | не трогаем | -| Предсерийные образцы | Open | Signed (`0x08`) | не трогаем | -| Серийное производство | **Closed** | Signed (`0x08`) | `SEC_CONFIG = 1` | +| Стадия | Режим HAB | Подпись | Fuse | +| --------------------- | ---------- | ----------------- | ---------------- | +| Разработка | Open | Unsigned (`0x00`) | не трогаем | +| Входной контроль | Open | Unsigned (`0x00`) | не трогаем | +| Предсерийные образцы | Open | Signed (`0x08`) | не трогаем | +| Серийное производство | **Closed** | Signed (`0x08`) | `SEC_CONFIG = 1` | > ⚠️ Запись `SEC_CONFIG = 1` необратима. Перед закрытием HAB необходимо убедиться, что подписанный образ успешно проходит верификацию на реальном железе. @@ -205,13 +241,13 @@ BootROM в режиме SDP умеет только писать в RAM и пр ## 9. Инструменты -| Инструмент | Назначение | -|---|---| -| `nxpimage hab export` | Сборка HAB-образа (IVT + BDT + DCD + опционально CSF) | -| `nxpimage hab parse` | Разбор готового образа для проверки | -| `sdphost` | Связь с BootROM в SDP режиме (запись в RAM, прыжок) | -| `blhost` | Связь с Flashloader (запись во Flash, конфигурация памяти) | -| `nxpdevscan` | Обнаружение подключённых NXP устройств | +| Инструмент | Назначение | +| --------------------- | ---------------------------------------------------------- | +| `nxpimage hab export` | Сборка HAB-образа (IVT + BDT + DCD + опционально CSF) | +| `nxpimage hab parse` | Разбор готового образа для проверки | +| `sdphost` | Связь с BootROM в SDP режиме (запись в RAM, прыжок) | +| `blhost` | Связь с Flashloader (запись во Flash, конфигурация памяти) | +| `nxpdevscan` | Обнаружение подключённых NXP устройств | --- diff --git a/firmware/bootloader/CMakeLists.txt b/firmware/bootloader/CMakeLists.txt index 999c460..5a49fd6 100644 --- a/firmware/bootloader/CMakeLists.txt +++ b/firmware/bootloader/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.20) project( bootloader - VERSION 0.1.0 + VERSION 1.0.0 LANGUAGES C ASM) set(TARGET_NAME bootloader) @@ -13,8 +13,8 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/src/version.h.in" "${CMAKE_CURRENT_BINARY_DIR}/generated/version.h" @ONLY) # bootutil (MCUboot Direct-XIP) + TinyCrypt + ASN.1 — общий список с -# host-тестами (tests/host/mcuboot_port/), см. firmware/bootloader/PLAN.md, -# Фаза 2. +# host-тестами (tests/host/mcuboot_port/), см. firmware/bootloader/PLAN.md, Фаза +# 2. include(${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/bootutil_sources.cmake) # bootloader_fatfs — bare-metal FatFS для чтения TFT_APP.BIN с SD (Фаза 3). @@ -38,7 +38,8 @@ add_executable( ${BSP_STARTUP_FILE} ${BSP_SYSCALLS_FILE}) -target_include_directories(${TARGET_NAME} PRIVATE src/ ${MCUBOOT_BOOTUTIL_INCLUDES}) +target_include_directories(${TARGET_NAME} PRIVATE src/ + ${MCUBOOT_BOOTUTIL_INCLUDES}) target_include_directories(${TARGET_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") @@ -49,15 +50,24 @@ target_compile_definitions( # ----------------------------------------------------------------------------- # Зависимости. bsp_button — downgrade-override (удержание BSP_BUTTON_1). -# bootloader_fatfs — чтение TFT_APP.BIN с SD (Фаза 3, firmware/bootloader/fatfs/). -# bsp_sdram — smoke-test SDRAM/SEMC (Фаза 4, bsp_sdram_configure()+_init()) — -# bootloader без DCD, сам поднимает SEMC на время диагностики. +# bootloader_fatfs — чтение TFT_APP.BIN с SD (Фаза 3, +# firmware/bootloader/fatfs/). bsp_sdram — smoke-test SDRAM/SEMC (Фаза 4, +# bsp_sdram_configure()+_init()) — bootloader без DCD, сам поднимает SEMC на +# время диагностики. # ----------------------------------------------------------------------------- -target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick - bsp_usb_cdc bsp_qspi_flash - bsp_boot_xip_no_dcd bsp_button - bsp_wdog bsp_boot_state - bsp_sdram bootloader_fatfs) +target_link_libraries( + ${TARGET_NAME} + PRIVATE bsp_board + bsp_led + bsp_tick + bsp_usb_cdc + bsp_qspi_flash + bsp_boot_xip_no_dcd + bsp_button + bsp_wdog + bsp_boot_state + bsp_sdram + bootloader_fatfs) # ----------------------------------------------------------------------------- # [DEV-ONLY] Диагностика Фазы 4 — CLI-команда "sdram_test" (dev_sdram_test.c): @@ -72,9 +82,9 @@ if(CMAKE_BUILD_TYPE STREQUAL "Debug") endif() # ----------------------------------------------------------------------------- -# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом -# bootloader (256 KB, см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). Без SDRAM -# (в отличие от firmware_test) — bootloader SDRAM не использует. +# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом bootloader +# (256 KB, см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). Без SDRAM (в отличие от +# firmware_test) — bootloader SDRAM не использует. # ----------------------------------------------------------------------------- target_link_options( ${TARGET_NAME} diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md deleted file mode 100644 index c0d7ffe..0000000 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md +++ /dev/null @@ -1,149 +0,0 @@ -# Аппаратная верификация — словарь LED-паттернов (Фаза 4) - -Чек-лист проверяет `led_status.c` на реальной плате: 5 сценариев из -[docs/bootloader/LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md) + опционально «неисправность -железа» (требует временной правки кода — раздел 6). Между сценариями 1-3 SWD-перепрошивка/power cycle -не нужны — бутлоадер сам пере-сканирует SD раз в ~1.5 с, доставать/менять файл на карте можно на ходу. - ---- - -## 0. Подготовка (один раз) - -```bash -# Собрать и прошить bootloader -just build::build-bootloader-debug -just host::flash-swd-bootloader-debug -# ОБЯЗАТЕЛЕН power cycle платы после прошивки — SWD не ресетит автоматически - -# Собрать подписанные образы-заглушки (test_stub) — понадобится stub_a_v1_confirmed.bin -just build::build-mcuboot-stub -ls build/Debug/signed/ # ожидаем stub_a_v1_confirmed.bin, stub_b_v2_confirmed.bin, ... - -# Стереть оба слота — чистая плата для сценариев 1-4 -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60040000+0x200000 -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60240000+0x200000 - -# Подготовить 2 тестовых файла для SD-карты (кладём рядом, на карту переносим по одному под именем TFT_APP.BIN) -head -c 512 /dev/urandom > /tmp/TFT_APP_garbage.bin - -cp build/Debug/signed/stub_a_v1_confirmed.bin /tmp/TFT_APP_badsig.bin -python3 -c " -with open('/tmp/TFT_APP_badsig.bin', 'r+b') as f: - f.seek(0x1000) # далеко за 32-байтным заголовком — magic/версия целы - b = f.read(1) - f.seek(0x1000) - f.write(bytes([b[0] ^ 0xFF])) # один перевёрнутый байт ломает hash/подпись -" - -# Открыть монитор порта — держать открытым все сценарии, смотреть и на CDC, и на LED -just host::uart-monitor -``` - -Power cycle после прошивки → плата должна войти в сценарий 1 сама. - ---- - -## 1. Норма — ждём SD - -**Подготовка:** SD-карта НЕ вставлена (или без файла `TFT_APP.BIN`). - -**Смотреть:** HEARTBEAT мигает 50 мс горит / 450 мс не горит. APP не горит вообще. - -**В мониторе:** каждые ~1.5 с — `{"type":"status","state":"waiting_for_sd"}`. - ---- - -## 2. Образ отклонён — битый файл (`SD_CANDIDATE_INVALID`) - -**Подготовка:** на SD-карту, под именем `TFT_APP.BIN`, положить `/tmp/TFT_APP_garbage.bin`. Вставить -карту (если уже была вставлена — просто заменить файл, ждать до ~1.5 с). - -**Смотреть:** APP делает ровно 4 быстрых вспышки (80 мс горит / 80 мс не горит), затем гаснет — -плата возвращается к паттерну «Норма» (пункт 1). HEARTBEAT всё это время не меняется. - -**В мониторе:** `{"ok":false,"error":"SD_CANDIDATE_INVALID"}`, следом снова `waiting_for_sd`. - ---- - -## 3. Образ отклонён — неверная подпись (`SD_INSTALL_REJECTED`) - -**Подготовка:** заменить файл на SD-карте на `/tmp/TFT_APP_badsig.bin` (переименовать в `TFT_APP.BIN`). - -**Смотреть:** сначала на секунду-две APP переходит в паттерн «Установка» (250/250, см. пункт 4 ниже — -файл пишется в Slot A, только потом проверка подписи проваливается), затем — те же 4 быстрых вспышки, -затем возврат к «Норме». - -**В мониторе:** `{"type":"status","state":"installing"}`, затем `{"ok":false,"error":"SD_INSTALL_REJECTED"}`, -следом снова `waiting_for_sd`. - ---- - -## 4. Установка — успешная (включая стирание слота) - -**Подготовка:** заменить файл на SD-карте на `build/Debug/signed/stub_a_v1_confirmed.bin` (переименовать -в `TFT_APP.BIN`). Это единственный образ, который можно ставить на чистую плату (см. §5.1 -`PLAN.md` — цель установки всегда Slot A, `stub_a_*` слинкован под её адрес; `stub_b_*` здесь -использовать нельзя — прыгнет в нерабочий код). - -**Смотреть:** -1. APP переходит в 250 мс вкл / 250 мс выкл. -2. Пока идёт стирание слота (~5 с) — мигание подвисает/дёргается, ровным 250/250 не выглядит. - **Это ожидаемо, не баг**: `bsp_qspi_erase_block_64k()` держит `__disable_irq()` на все ~150 мс - каждого блока — глушится и SysTick, от которого тикает `bsp_tick_get_ms()`, часы паттерна на это - время фактически стоят. Известное ограничение, см. `@note` у `led_status_tick_install()` - (`led_status.h`) — сознательно не чиним (означало бы не маскировать IRQ на время IP-команды - стирания, прямой путь к HardFault на XIP). -3. Во время копирования (после стирания, несколько секунд) — мигание заметно более гладкое, чем на - шаге 2: там каждая запись страницы держит IRQ всего ~3 мс, а не ~150 мс. -4. По завершении — оба LED гаснут (бутлоадер передал управление), затем APP начинает мигать в паттерне - самого `test_stub` (500 мс вкл / 250 мс выкл) — это уже не бутлоадер, а другая прошивка. - -**В мониторе:** `{"type":"status","state":"installing"}`, дальше тишина по протоколу бутлоадера (CDC -переходит под управление `test_stub`, если он вообще что-то шлёт). - ---- - -## 5. Recovery Mode - -**Подготовка:** удержать `BSP_BUTTON_2` в момент подачи питания (после сценария 4 в Slot A уже валидный -образ — это не мешает, `BTN 2` проверяется раньше выбора слота). - -**Смотреть:** оба светодиода горят и гаснут ОДНОВРЕМЕННО: 100 мс горят / 100 мс не горят. - -**В мониторе:** `{"type":"status","state":"recovery_mode"}`. - -**Дополнительно (не обязательно):** если в этот момент вставить SD с валидным образом — на несколько -секунд паттерн переключится на «Установка» (250/250, как в пункте 4), затем плата стирает оба слота и -прыгает. Это ожидаемо, не баг: пока установка идёт, экран рисует именно она, recovery-паттерн -возвращается только если установка не состоялась. - ---- - -## 6. (Опционально) Неисправность железа - -Настоящую неисправность QSPI/SDRAM на живой плате не устроить без пайки — этот пункт использует -временную правку одной строки, чтобы искусственно провалить проверку ёмкости QSPI. **Не забыть -откатить после проверки.** - -**Подготовка:** -1. В `firmware/bootloader/src/main.c` найти строку: - ```c - const uint32_t QSPI_MIN_FLASH_SIZE_MB = 16U; - ``` - Временно поднять порог выше реальной ёмкости чипа на плате (если на плате W25Q128 — 16 МБ, - поставить, например, `32U`). -2. Пересобрать и прошить: `just build::build-bootloader-debug && just host::flash-swd-bootloader-debug`. -3. Power cycle. - -**Смотреть:** HEARTBEAT мигает как обычно (50/450). APP мигает быстро без остановки: 100 мс горит / -100 мс не горит — и не останавливается (устойчиво до следующего POR). - -**В мониторе:** `{"type":"qspi_info","chip":"W25Q128","...","pass":false}`. - -**Откатить обязательно:** -1. Вернуть строку в `main.c` к `16U`. -2. Пересобрать и прошить заново: `just build::build-bootloader-debug && just host::flash-swd-bootloader-debug`. -3. Power cycle, убедиться что плата снова показывает «Норма» (пункт 1) — не оставлять плату/дерево - с искусственно применённым порогом. diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md deleted file mode 100644 index ba923d8..0000000 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md +++ /dev/null @@ -1,120 +0,0 @@ -# Фаза 2 (bootutil / MCUboot Direct-XIP) — аппаратная верификация - -Памятка с готовыми командами: сборка, прошивка, стирание, чек-лист. - -Все команды — из корня репозитория (`tft_manufacture_test/`). - ---- - -## 0. Предпосылки - -- `firmware/bootloader` собран с ARM-стороной Фазы 2 (bootutil + `flash_map_backend.c` над - `bsp_qspi_flash` + `boot_select.c`). `main.c` пытается `boot_go()` сразу после минимального - bring-up, до подъёма USB CDC — так что плата грузит tft_app и без подключённого кабеля. -- Заглушка вместо ещё не существующего `tft_app` — `firmware/bootloader/test_stub/`: два образа, - различающиеся частотой мигания `LED_APP` (по частоте видно, какой слот реально выбрал bootloader). -- Оба образа подписаны тестовым sample-ключом MCUboot (`sdk/middleware/mcuboot_opensource/root-ec-p256.pem`) - — публичный, не для продакшена, годится только для этой проверки. - ---- - -## 1. Сборка - -```bash -# Bootloader (Debug) + HAB-контейнер -just build::build-bootloader-debug -just build::hab-bootloader-debug - -# Заглушки Slot A/Б — собрать И подписать imgtool'ом одной командой -just build::build-mcuboot-stub -``` - -После этого в `build/Debug/` должны появиться: - -```bash -bootloader_hab.bin -signed/stub_a_v1_confirmed.bin (2 МБ, v1.0.0, --confirm) -signed/stub_b_v2_confirmed.bin (2 МБ, v2.0.0, --confirm) -signed/stub_a_v1_unconfirmed.bin (2 МБ, v1.0.0, без --confirm — для revert) -``` - -Если файлов нет или размер не 2 МБ — пересобрать: -`rm -rf build/Debug && just build::build-bootloader-debug && just build::hab-bootloader-debug && just build::build-mcuboot-stub`. - ---- - -## 2. Прошивка bootloader - -```bash -just host::flash-swd-bootloader-debug -# обязателен power cycle платы после прошивки — SWD-запись не ресетит автоматически -``` - ---- - -## 3. Прошивка образов в слоты (pyOCD) - -Не через `flash_swd.py` — тот собирает FCB+IVT+HAB под `0x60000000`, слотам это не нужно (не -самостоятельный boot-образ для BootROM, а данные, которые читает `boot_go()`). Пишем сырой -подписанный `.bin` напрямую по адресу слота. - -**Важно:** `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd`, не только у -`uv` — путь к `.bin` должен быть абсолютным (`"$(pwd)/..."`), иначе резолвится от `tools/hil/` и -получите `No such file`. - -```bash -# Slot A (0x60040000) — валидный, confirmed -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin" - -# Slot Б (0x60240000) — валидный, confirmed, версия новее -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_v2_confirmed.bin" - -# Slot A — неподтверждённый вариант, для проверки revert (использовать ВМЕСТО confirmed-варианта) -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_unconfirmed.bin" -``` - ---- - -## 4. Стирание слота - -Адрес — позиционный аргумент (не `-a`), формат диапазона `start+length` (не `start@length`). - -```bash -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60040000+0x200000 # Slot A, 2 МБ - -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60240000+0x200000 # Slot Б, 2 МБ -``` - ---- - -## 5. Проверка "bootloader жив" (CDC) - -Актуально для сценария 4 (оба слота пусты — bootloader не прыгает, остаётся в диагностическом цикле). -Подключиться к USB CDC ACM платы (см. [README.md](README.md#3-подключение)) любым терминалом: - -```bash -screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого подключения -``` - -Отправить `{"type":"cmd","cmd":"ping"}` — в ответ должно прийти `{"type":"pong"}`. - ---- - -## 6. Чек-лист сценариев - -Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись не ресетит автоматически). - -| № | Сценарий | Подготовка | Ожидаемый результат | -| --- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| 1 | Валиден только Slot A | Erase Slot Б (шаг 4), `stub_a_v1_confirmed.bin` → Slot A (шаг 3) | `LED_APP` мигает ~1 раз/сек | -| 2 | Оба валидны, побеждает версия Б | + `stub_b_v2_confirmed.bin` → Slot Б | `LED_APP` мигает ~2 раза/сек | -| 3 | Slot Б повреждён | В Slot Б — испорченный файл (скопировать `stub_b_v2_confirmed.bin`, поменять один байт в payload, прошить тем же способом что и в шаге 3) | `LED_APP` возвращается к ~1 разу/сек (снова Slot A) | -| 4 | Оба слота пусты | Erase Slot A и Slot Б целиком (шаг 4, оба вызова) | `LED_APP` не мигает по образцу заглушки; `ping` по CDC (шаг 5) отвечает `pong` — bootloader не прыгнул, остался в своём цикле | -| 5 | Revert неподтверждённого образа | `stub_a_v1_unconfirmed.bin` → Slot A; power cycle (1) → LED мигает (образ выбран впервые, `copy_done` выставляется); power cycle (2) БЕЗ каких-либо действий между ними → Slot A должен быть стёрт bootutil'ом | После второго ресета — как сценарий 4 (LED не мигает, CDC ping жив) | - ---- diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md deleted file mode 100644 index b9523a2..0000000 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md +++ /dev/null @@ -1,409 +0,0 @@ -# Фаза 3 (SD-путь установки) — аппаратная верификация - -Памятка с готовыми командами: сборка, прошивка, подготовка SD-карты, чек-лист. Продолжение -[HARDWARE_VERIFICATION_PHASE2.md](HARDWARE_VERIFICATION_PHASE2.md) — предполагает, что прошивка -bootloader и заглушки слотов (`test_stub`) уже знакомы по Фазе 2. - -Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer; -прошивка/отладка/проверки на железе — на стороне пользователя. - -**Статус: ✅ все 5 сценариев §8 пройдены на реальной плате (2026-07-10, повторно — с раундом 4 и -watchdog — 2026-07-13); watchdog §9 (9.1–9.5) пройден 2026-07-13.** Историю бага card-detect и его -разрешение (чтение USDHC `PRES_STATE.CINST`) см. в [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md). -По пути найден и исправлен баг самого чек-листа (не кода) — сценарий 1 использовал не тот линкованный -стаб, см. [../PLAN.md](../PLAN.md), раздел «Найден и исправлен баг чек-листа». - ---- - -## 0. Предпосылки - -- `firmware/bootloader` собран с ARM-стороной Фазы 3 (`update_policy` + `slot_version` + `sd_update` + - `bootloader_fatfs`). `main.c` перед выбором слота сканирует microSD: если вставлена и на ней лежит - подписанный `TFT_APP.BIN` — по политике версий (или по удержанию кнопки) ставит его в НЕактивный - слот, затем `boot_select_and_jump()`. -- Заглушка вместо ещё не существующего `tft_app` — та же `firmware/bootloader/test_stub/`, что и в - Фазе 2: два образа, различающиеся частотой мигания `LED_APP` (по частоте видно, какой слот реально - выбран). Оба подписаны тестовым sample-ключом MCUboot. -- **Card detect** — через регистр USDHC `PRES_STATE.CINST`, не GPIO (см. DEBUG_LOG). На пустом слоте - гейт `bsp_sd_is_inserted()` обязан быть верным, иначе блокирующий `SD_PollingCardInsert()` без - тайм-аута подвесит загрузчик; PRSSTAT это обеспечивает. -- **Обратная связь** — USB CDC ACM, тот же JSON-lines протокол, что в Фазе 1/2, плюс события - `status` (см. §6). - ---- - -## 1. Сборка - -```bash -# Bootloader (Debug) + HAB-контейнер -just build::build-bootloader-debug -just build::hab-bootloader-debug - -# Заглушки Slot A/Б — собрать И подписать imgtool'ом одной командой -just build::build-mcuboot-stub -``` - -После этого в `build/Debug/` — те же файлы, что в Фазе 2: - -```bash -bootloader_hab.bin -signed/stub_a_v1_confirmed.bin (2 МБ, v1.0.0, --confirm) -signed/stub_b_v2_confirmed.bin (2 МБ, v2.0.0, --confirm) -signed/stub_a_v1_unconfirmed.bin (2 МБ, v1.0.0, без --confirm) -``` - -Частоты мигания `LED_APP` (по ним отличаем слот): **stub_a (v1) ≈ 1 раз/сек**, **stub_b (v2) ≈ 2 -раза/сек**. - ---- - -## 2. Прошивка bootloader - -```bash -just host::flash-swd-bootloader-debug -# обязателен power cycle платы после прошивки — SWD-запись не ресетит автоматически -``` - ---- - -## 3. Прошивка образов в слоты напрямую (pyOCD) - -Нужно только для сценариев, где слот должен быть заполнен ДО теста (2, 3, 5) — установка с SD слоты -заполняет сама. Способ идентичен Фазе 2 (сырой подписанный `.bin` по адресу слота, не через -`flash_swd.py`). - -**Важно:** `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd` — путь к `.bin` -должен быть абсолютным (`"$(pwd)/..."`). - -```bash -# Slot A (0x60040000) -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin" - -# Slot Б (0x60240000) -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_v2_confirmed.bin" -``` - ---- - -## 4. Стирание слота - -Адрес — позиционный аргумент, формат диапазона `start+length`. - -```bash -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60040000+0x200000 # Slot A, 2 МБ - -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60240000+0x200000 # Slot Б, 2 МБ -``` - ---- - -## 5. Подготовка SD-карты - -Карта — FAT32/FAT16. Bootloader ищет в корне файл **`TFT_APP.BIN`** (путь `2:/TFT_APP.BIN`, диск `2:` -— внутренняя нумерация FatFS, физически это корень карты). Файл — подписанный образ-заглушка, -просто переименованный. - -> ⚠️ **Стабы не PIC — файл на SD должен быть линкован под адрес слота, в который он реально попадёт.** -> `stub_a_*` линкован под `0x60040000` (Slot A), `stub_b_*` — под `0x60240000` (Slot Б); абсолютные -> адреса (таблица векторов, литералы) зашиты в бинарь при линковке. Установка "не того" файла в слот -> проходит crypto-гейт (подпись валидна для своего же содержимого) и доходит до прыжка, но -> исполняется код, рассчитанный на другой адрес — эффективно ничего не запускается, без ошибки на -> CDC. Тот же открытый вопрос, что и в Фазе 2 ([../PLAN.md](../PLAN.md), "разный адрес — намеренно... -> открытый вопрос из BOOTLOADER_FLASH_MAP.md §4") и уже задокументированный в проде как gap одного -> `TFT_APP.BIN` в [../../../docs/mimxrt1052/UPDATE_FLOW.md](../../../docs/mimxrt1052/UPDATE_FLOW.md). -> **Целевой слот определяет `update_policy_decide()`** (всегда НЕ активный слот; на пустой плате -> активного слота нет → по умолчанию Slot A) — файл на SD должен быть собран под этот же адрес. - -```bash -# Сценарий 1 (чистая плата — целевой слот всегда А, т.к. активного слота нет) — -# ОБЯЗАТЕЛЬНО stub_a, не stub_b: stub_b линкован под Slot Б и не запустится в Slot A. -cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes//TFT_APP.BIN - -# Сценарий 2/3 (Slot Б уже занят под v2 — целевой слот снова А, т.к. Slot Б активен) — -# тот же stub_a, сценарии отличаются только удержанием кнопки, см. §7 и таблицу §8. -cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes//TFT_APP.BIN - -# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED). -# Линковка тут неважна — кандидат отклоняется до записи/выбора, ни разу не исполняется. -cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes//TFT_APP.BIN -# затем поменять один байт в СЕРЕДИНЕ файла (payload), напр. в hex-редакторе — magic в начале не трогать -# Кандидат с битым ЗАГОЛОВКОМ (magic) — сценарий 4a (SD_CANDIDATE_INVALID), линковка тоже неважна -# поменять один из первых 4 байт файла (ih_magic) -``` - -Берём `--confirm`-варианты: иначе установленный с SD образ на следующем power cycle -откатится (Direct-XIP Revert, см. Фаза 2 / сценарий 5 там). - -> ℹ️ **Про повторный скан карты без ресета (раунд 4).** Детект сведён к единому механизму — пин -> постоянно на `USDHC1_CD_B`, и гейт, и внутренний детект SDK читают USDHC `PRES_STATE.CINST` (item -> 1, см. DEBUG_LOG). Прежняя хрупкость (после первого `bsp_sd_init()` пин уходил на `GPIO2_IO28` и -> пере-скан ослеп бы) устранена. **Стоит отдельно проверить** кейс, который раньше был сломан: карта -> вставлена, но `TFT_APP.BIN` появляется/кладётся на неё БЕЗ извлечения и перезагрузки — пере-скан в -> цикле ожидания (~1.5 c) должен его подхватить. Между полноценными сценариями power cycle всё равно -> обязателен (см. ниже). - ---- - -## 6. CDC-статусы и проверка "bootloader жив" - -Подключиться к USB CDC ACM платы любым терминалом (см. [../README.md](../README.md)): - -```bash -screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого подключения -``` - -- Живость: отправить `{"type":"cmd","cmd":"ping"}` → ответ `{"type":"pong"}`. -- События SD-пути (`{"type":"status","state":"..."}`): - | state | когда | - | ----------------- | ------------------------------------------------------------------ | - | `waiting_for_sd` | периодически в цикле ожидания (нет валидного слота, ждём SD) | - | `installing` | принято решение установить кандидата, начинается запись в слот | - | `update_skipped` | кандидат отклонён по версии (старше/равен, кнопка не удержана) | -- Ошибки (`{"ok":false,"error":"..."}`): - | error | когда | - | --------------------------- | -------------------------------------------------------------- | - | `SD_CANDIDATE_INVALID` | битый magic заголовка кандидата (до записи во flash) | - | `SD_INSTALL_WRITE_FAILED` | сбой записи/verify чанка во flash | - | `SD_INSTALL_REJECTED` | образ записан, но пост-проверка (hash+ECDSA) не прошла | - | `SD_DOWNGRADE_ERASE_FAILED` | не удалось стереть прежний активный слот при форс. даунгрейде | -- Watchdog (`{"type":"wdog",...}`): запросить `{"type":"cmd","cmd":"wdog"}` → - `{"type":"wdog","armed":true,"timeout_s":10,"recovered":false}`. `recovered:true` означает, что - предыдущий сброс был по таймауту watchdog (плата восстановилась после зависания) — то же событие - эмитится автоматически один раз при старте, если восстановление произошло. - ---- - -## 7. Удержание кнопки даунгрейда (BSP_BUTTON_1) - -**Держать `BSP_BUTTON_1` нажатой в момент подачи питания** (при включении). Состояние считывается -один раз в первые миллисекунды после `board_hw_init()` — сразу после `bsp_button_init()`, ДО -обращения к SD — и защёлкивается на всю сессию. Долгое удержание не требуется: важно, чтобы кнопка -была нажата на момент включения. - -> **Изменение поведения (раунд 4, требует ре-проверки):** раньше кнопка читалась глубоко внутри -> `run_update()` — уже после `f_mount()` и двух крипто-валидаций слотов, то есть через несколько -> секунд слепого окна (приходилось держать ~15 c, пока по CDC не придёт `installing`). Теперь сэмпл -> ранний и защёлкнутый (`main.c` → `downgrade_held` → `sd_update_check(...)`), см. -> [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md), раунд 4 / item 2. При проверке сценария 3 -> достаточно удерживать кнопку при включении. - -`bsp_button_read()` — мгновенное сырое чтение, без debounce и latch; жест — именно «нажато в момент -старта», не «нажать после». - ---- - -## 8. Чек-лист сценариев - -Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись/установка не ресетят -автоматически). Зеркалит план верификации Фазы 3 (см. [../PLAN.md](../PLAN.md)). - -| № | Сценарий | Подготовка | Ожидаемый результат | -| --- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| 1 | Чистая плата + валидный образ на SD | Erase Slot A и Slot Б (§4); SD с `TFT_APP.BIN` = `stub_a_v1_confirmed.bin` (§5, **не** stub_b — см. предупреждение о PIC там); вставить карту | CDC: `installing`; авто-установка в Slot A → прыжок; `LED_APP` мигает ~1/сек (v1) | -| 2 | Кандидат старше установленного, без кнопки | Slot Б = `stub_b_v2_confirmed.bin` (§3); SD `TFT_APP.BIN` = `stub_a_v1_confirmed.bin` (v1); кнопку НЕ трогать | CDC: `update_skipped`; прыжок в существующий Slot Б; `LED_APP` ~2/сек (v2 не тронут) | -| 3 | Форс. даунгрейд по кнопке | Как сценарий 2, но **удерживать `BSP_BUTTON_1` нажатой в момент подачи питания** (см. §7) | CDC: `installing`; v1 ставится в Slot A, прежний активный Slot Б стирается; прыжок в v1; `LED_APP` ~1/сек | -| 4a | Битый заголовок кандидата | SD `TFT_APP.BIN` с испорченным magic (§5); слоты — как удобно (напр. Slot A = v1) | CDC: `error: SD_CANDIDATE_INVALID`; плата остаётся на прежнем валидном слоте (или ждёт SD, если слотов нет) | -| 4b | Битая подпись/TLV кандидата | SD `TFT_APP.BIN` с целым magic, но испорченным payload (§5) | CDC: `installing` затем `error: SD_INSTALL_REJECTED`; прежний валидный слот не тронут (записанный кандидат не выбирается) | -| 5 | Бандл залит в слот заранее (SD не участвует) | `stub_a_v1_confirmed.bin` → Slot A (§3); карту НЕ вставлять | SD-логика не входит; прыжок в Slot A; `LED_APP` ~1/сек. `ping`→`pong` при желании | - -**Инструмент подтверждения «прыжок реально произошёл»** — тот же, что в Фазе 2: частота мигания -`LED_APP` (500 мс vs 250 мс) однозначно указывает на выбранный слот, верифицируемо глазами. - -> ⚠️ **Watchdog и заглушка.** Начиная с этого раунда загрузчик взводит аппаратный WDOG (10 c) перед -> прыжком, а `WDE` — write-once (выключить нельзя). Заглушка `test_stub` его кормит, поэтому мигает -> как раньше. Если прыгнуть в образ, который WDOG НЕ кормит, плата будет ресетиться каждые ~10 c — -> для будущего `tft_app` это обязательный контракт (см. [../../../bsp/wdog/README.md](../../../bsp/wdog/README.md)). - ---- - -## 9. Watchdog — провокация зависания и подтверждение восстановления - -Цель: убедиться, что аппаратный WDOG1 (a) реально сбрасывает плату при зависании в блокирующем коде, -(b) даёт восстановление (прыжок в валидный слот после сброса) без второго вмешательства оператора, -(c) корректно сообщает об этом по CDC, (d) не мешает нормальной работе и отладке. - -Раунд 3 закрыл конкретный баг card-detect (§0), поэтому исходный сценарий «пустой слот CD, физическое -выдёргивание карты» больше не воспроизводится детерминированно тем же путём. Вместо охоты за реальной -гонкой — **синтетическое зависание, включаемое временным патчем и гарантированно однократное**: код -проверяет `bsp_wdog_caused_last_reset()` и виснет только в том прогоне, где предыдущий сброс не был -watchdog-сбросом (т.е. только на первом POR-старте). Второй (WDOG-инициированный) сброс уже не -попадает в ветку зависания — плата продолжает штатный boot без ручного вмешательства между двумя -шагами. Один power cycle — весь тест, включая наблюдение восстановления. - -**Общие предпосылки для 9.1 и 9.2:** -- В Slot A — валидный подтверждённый `stub_a_v1_confirmed.bin` (см. §3), чтобы после WDOG-сброса - плате было куда восстанавливаться. -- Патчи в этом разделе — **временные, не коммитить**. После каждого теста — `git diff` / `git - checkout -- firmware/bootloader/src/...` перед переходом к следующему шагу. -- Секундомер (или просто ощущение "около 10 c") достаточен — таймаут не настолько короткий, чтобы - нужен был осциллограф. - ---- - -### 9.1 Тест A — механизм WDOG1 напрямую (без SD, без карты) - -Изолирует сам аппаратный таймер от всей SD/USDHC-логики: гарантированный ноль `bsp_wdog_refresh()` -после старта. - -**Патч** (`firmware/bootloader/src/main.c`, сразу после `bsp_wdog_init()`, до `bsp_led_init()`): - -```c - (void) bsp_wdog_init(WDOG_TIMEOUT_S); - - /* ВРЕМЕННО — тест 9.1, не коммитить */ - if (!bsp_wdog_caused_last_reset()) - { - for (;;) { } - } - - bsp_led_init(); -``` - -**Сборка и прошивка:** - -```bash -just build::build-bootloader-debug -just host::flash-swd-bootloader-debug -``` - -**Прогон (один power cycle):** - -1. Подать питание (или физический power cycle, не ресет через отладчик — нужен настоящий POR, чтобы - `WDOG1->WRSR`/`SRC->SRSR` были в чистом состоянии). -2. Наблюдение: ничего не мигает, ничего не инициализируется (зависание — до `bsp_led_init()`), - CDC-порт не появляется. -3. Через **~10 c** — аппаратный сброс. На этот раз `bsp_wdog_caused_last_reset()` вернёт `true` (сброс - был по WDOG), условие ложно — зависания не будет, выполнение идёт дальше штатно: - `bsp_led_init()` → ... → `boot_select_and_jump()` → прыжок в Slot A. `LED_APP` начинает мигать - ~1/сек (частота `stub_a`) — это и есть подтверждение восстановления, без второго power cycle. -4. Подключиться по CDC и запросить статус: - - ```bash - screen /dev/cu.usbmodemXXXX - {"type":"cmd","cmd":"wdog"} - ``` - - Ожидаемый ответ: `{"type":"wdog","armed":true,"timeout_s":10,"recovered":true}`. - -**Итог теста A:** таймаут ≈10 c подтверждён по секундомеру, `recovered:true` подтверждён по CDC, -восстановление в валидный слот подтверждено по LED — без ручного вмешательства между зависанием и -восстановлением. - ---- - -### 9.2 Тест B — реалистичный сценарий (внутри SD-пути, требует вставленной карты) - -Тот же приём gate-инга по `bsp_wdog_caused_last_reset()`, но зависание — в точке, где исторически уже -дважды зависал реальный SD/USDHC-стек (см. [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md)): -сразу после успешного `bsp_sd_init()`, перед `f_mount()`. - -**Патч** (`firmware/bootloader/src/sd_update.c`, в `run_update()`, сразу после проверки -`bsp_sd_init()`): - -```c - if (bsp_sd_init() != BSP_OK) - { - return; - } - - /* ВРЕМЕННО — тест 9.2, не коммитить */ - if (!bsp_wdog_caused_last_reset()) - { - for (;;) { } - } - - if (f_mount(&g_s_fs, SD_UPDATE_MOUNT_POINT, 1) != FR_OK) -``` - -**Предпосылка, отличная от 9.1:** SD-карта должна быть **физически вставлена** при подаче питания — -иначе `sd_update_check()` вернётся по `bsp_sd_is_inserted() == false` ещё до `run_update()`, и -патч не сработает вообще. Содержимое карты не важно (можно пустую/неотформатированную — до чтения -файловой системы код не доходит). - -**Сборка, прошивка, прогон** — идентично 9.1 (`just build::build-bootloader-debug`, -`just host::flash-swd-bootloader-debug`, один power cycle с картой в слоте). Ожидаемая картина: LED -успевает пройти обычную раннюю инициализацию (heartbeat может пару раз моргнуть, пока не дошло до -`sd_update_check()`), дальше зависание на ~10 c, автоматический WDOG-сброс, на втором прогоне (тот же -power cycle, вмешательство не требуется) — штатное продолжение и прыжок в Slot A. CDC-проверка — -как в 9.1, п. 4. - -**Итог теста B:** подтверждает защиту именно того класса зависаний, ради которого watchdog и -проектировался — блокирующий вызов в SD-пути, не возвращающий управление. - ---- - -### 9.3 (опционально) Прямое чтение регистров через pyOCD - -Дополнительное, не обязательное подтверждение на уровне регистров — если хочется убедиться в причине -сброса, не полагаясь только на прошивку/CDC. **Осторожно:** подключение отладчика само по себе может -повлиять на состояние ядра в зависимости от режима коннекта — трактовать как вспомогательную, а не -основную проверку; основные критерии успеха — секундомер + LED + CDC-ответ из 9.1/9.2. - -```bash -# WDOG1->WRSR (0x400B8004, 16-бит) — бит1 (0x2) = TOUT, сброс был по watchdog -uv run --directory tools/hil pyocd commander --target mimxrt1050_quadspi --frequency 4000000 \ - -c "read16 0x400B8004" - -# SRC->SRSR (0x400F8008, 32-бит) — бит4 (0x10) = WDOG_RST_B, та же причина на уровне SRC -uv run --directory tools/hil pyocd commander --target mimxrt1050_quadspi --frequency 4000000 \ - -c "read32 0x400F8008" -``` - -Оба регистра read-only снимки последнего сброса — валидны сразу после WDOG-сброса, до следующего -любого сброса (в т.ч. до ресета самим отладчиком при коннекте — читать значение сразу первой командой -сессии, ничего не делать до этого). - ---- - -### 9.4 Регрессия — «не мешает нормальной работе» - -Обязательно после 9.1/9.2, перед закрытием пункта: - -1. Откатить оба временных патча (`git diff firmware/bootloader/src/main.c - firmware/bootloader/src/sd_update.c` должен быть пустым), пересобрать и перепрошить: - - ```bash - just build::build-bootloader-debug - just host::flash-swd-bootloader-debug - ``` - -2. Power cycle, подключиться по CDC, запросить `{"type":"cmd","cmd":"wdog"}` — ожидается - `"recovered":false` (последний сброс — обычный power-on, не watchdog). -3. Прогнать чек-лист §8 (сценарии 1–5) как есть — все должны проходить без единого неожиданного - сброса. WDOG кормится во всех легитимных долгих операциях (стирание слота, потоковое копирование, - цикл ожидания SD) — сценарии с самой длинной легитимной работой (установка образа, сценарии 1 и 3) - — лучший регрессионный индикатор: если бы кормление где-то пропустили, именно они словили бы - ложный сброс посреди операции. -4. Оставить плату в цикле ожидания (без SD, оба слота с валидными образами не трогать) на 2+ минуты, - убедиться, что `LED_HEARTBEAT`/`LED_APP` продолжают штатный паттерн без сбросов — таймаут 10 c - означает, что пропуск хотя бы одного кормления в цикле проявился бы в пределах первой минуты. - ---- - -### 9.5 Поведение под отладчиком (SWD halt) - -Подтвердить, что `enableDebug=false` действительно приостанавливает WDOG под отладкой — иначе -пошаговая отладка любого будущего кода после `bsp_wdog_init()` была бы невозможна: - -1. Запустить `🐛 Debug: bootloader` (см. Фаза 1), поставить брейкпоинт после `bsp_wdog_init()`. -2. Остановиться на нём и держать паузу **дольше 10 c** (просто подождать, не резюмировать). -3. Ожидаемо: сброса не происходит — после `Continue` выполнение продолжается с той же точки, как будто - таймер не тикал во время halt. - ---- - -### 9.6 Итоговый чек-лист - -| № | Тест | Ожидаемый результат | -|---|---|---| -| 9.1 | Синтетическое зависание сразу после `bsp_wdog_init()`, без SD | Сброс ≈10 c после POR; авто-восстановление в Slot A без второго power cycle; CDC `wdog` → `recovered:true` | -| 9.2 | Синтетическое зависание в `run_update()` после `bsp_sd_init()`, карта вставлена | То же поведение, но на пути, воспроизводящем исторический класс SD/USDHC-зависаний | -| 9.3 (опц.) | Чтение `WDOG1->WRSR`/`SRC->SRSR` через pyOCD | Бит причины сброса (`TOUT`/`WDOG_RST_B`) установлен сразу после WDOG-сброса | -| 9.4 | Регрессия: чек-лист §8 без патчей, ожидание 2+ мин в цикле | Ни одного ложного сброса; `wdog` → `recovered:false` | -| 9.5 | SWD-halt дольше 10 c | Сброса нет, отладка не сбивается | - ---- diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md deleted file mode 100644 index 1464b03..0000000 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md +++ /dev/null @@ -1,330 +0,0 @@ -# Фаза 6 (recovery) — аппаратная верификация - -Памятка с готовыми командами: сборка, прошивка, подготовка стенда, чек-лист. Продолжение -[HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md) — предполагает, что прошивка -bootloader и базовые заглушки слотов (`test_slot_stub_a`/`_b`) уже знакомы по Фазам 2/3. - -Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer; -прошивка/отладка/проверки на железе — на стороне пользователя. - -**Статус (2026-07-14): сценарии 1-4 пройдены.** По пути два найденных и исправленных пробела (см. -PLAN.md, разделы "Найден и исправлен баг (на железе, сценарий 2/4)"): -- Сценарий 2 сначала не проходил — стенд (`test_stub`) обнулял счётчик попыток раньше, чем - зависание успевало засчитаться; после фикса и переподписи (`just build::build-mcuboot-stub`) — - подтверждён. -- Сценарий 4 сначала не проходил — сам чек-лист (этот файл) предписывал не тот `TFT_APP.BIN` - (линкован под чужой слот); исправлен текст сценария 4 ниже + попутно найден и исправлен реальный - пробел в `main.c` (счётчик не инкрементировался на первом прыжке в свежий recovery-образ). После - фикса — подтверждён. - -Сценарий 5 (транзиент/per-session) — подтверждён: POR без BTN_2 обнуляет счётчик, зависший слот -пробуется заново, детерминированное зависание повторяет цикл 1→2→3 и снова приходит в -`recovery_mode` — ровно ожидаемая per-session семантика (не отдельный флеш-маркер). - -Сценарий 6 (регрессия) — подтверждён: чек-лист Фазы 3 + пустая плата 2+ мин без единого ложного -recovery/сброса. - -**✅ Все 6 сценариев Фазы 6 пройдены на реальной плате (2026-07-14).** - ---- - -## 0. Предпосылки - -- `firmware/bootloader` собран с кодом Фазы 6: `bsp/boot_state` (счётчик попыток в `SRC_GPR3`, - POR-детект), `recovery.c` (`recovery_decide()` — таксономия классов B/C/D), `update_policy.c` - (флаг `recovery_mode` — ослабленный gate), `main.c` (`attempt_boot()` — recovery-гейт → SD-скан → - прыжок, LED-паттерн, CDC-статус). -- Класс A (незавершённая установка) закрывается штатным revert MCUboot автоматически — код - загрузчика в этом не участвует, проверяется тем не менее (сценарий 1 ниже), т.к. счётчик всё равно - инкрементируется на каждой такой попытке (см. находку про фикс счётчика в PLAN.md). -- Watchdog (Фаза 3) уже аппаратно верифицирован — таймаут 10 c, см. - [HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md), раздел 9. Здесь ту же - 10-секундную защиту используем как механизм провокации, не проверяем заново. -- Порог фолбэка/recovery — `RECOVERY_DEFAULT_THRESHOLD = 3` (сбросов подряд). - ---- - -## 1. Сборка - -```bash -# Bootloader (Debug) + HAB-контейнер -just build::build-bootloader-debug -just build::hab-bootloader-debug - -# Стаб-образы (базовые Фазы 2/3 + новые Фазы 6) — одной командой -just build::build-mcuboot-stub -``` - -После этого в `build/Debug/signed/` — 7 подписанных файлов, все ровно по 2 МБ: - -| Файл | Версия | Слот (адрес линковки) | Подтверждён при прошивке | Поведение в рантайме | -|---|---|---|---|---| -| `stub_a_v1_confirmed.bin` | 1.0.0 | Slot A (`0x60040000`) | да (`--confirm`) | здоров, никогда не виснет, мигает ~1/сек | -| `stub_b_v2_confirmed.bin` | 2.0.0 | Slot Б (`0x60240000`) | да | здоров, никогда не виснет, мигает ~2/сек | -| `stub_a_v1_unconfirmed.bin` | 1.0.0 | Slot A | нет | здоров, но revert-тест Фазы 2 (не используется здесь) | -| `stub_a_hang_class_a.bin` | 1.0.0 | Slot A | нет | **Класс A**: никогда не подтверждается, виснет СРАЗУ | -| `stub_b_hang_class_a.bin` | 2.0.0 | Slot Б | нет | то же, для Slot Б | -| `stub_a_confirm_hang_class_b.bin` | 1.0.0 | Slot A | да | **Класс B**: подтверждается сразу, мигает ~6 c, виснет | -| `stub_b_confirm_hang_class_b.bin` | 2.0.0 | Slot Б | да | то же, для Slot Б | - -> ⚠️ **Версии фиксированы по слоту линковки (1.0.0 = A, 2.0.0 = Б) — это ограничивает выбор файлов -> для сценария "класс B, есть фолбэк".** `boot_go()` всегда выбирает более высокую версию как -> активную. Если положить `stub_a_confirm_hang_class_b.bin` (v1.0.0) в Slot A вместе со -> `stub_b_v2_confirmed.bin` (v2.0.0) в Slot Б — активным станет Slot Б (здоровый!), а зависающий -> Slot A вообще не будет выбран, и тест ничего не покажет. **Правильная комбинация для "есть -> фолбэк"**: `stub_b_confirm_hang_class_b.bin` (v2.0.0, виснет) в Slot Б + `stub_a_v1_confirmed.bin` -> (v1.0.0, здоров) в Slot A — тогда активный (Slot Б, выше версия) реально виснет, а фолбэк (Slot A, -> валиден) ждёт своей очереди. См. сценарий 2 ниже — именно эта комбинация. - ---- - -## 2. Прошивка bootloader - -```bash -just host::flash-swd-bootloader-debug -# обязателен power cycle платы после прошивки -``` - ---- - -## 3. Прошивка образов в слоты напрямую (pyOCD) - -Тот же способ, что в Фазах 2/3 — сырой подписанный `.bin` напрямую по адресу слота, путь абсолютный -(`"$(pwd)/..."`, команда — из корня репозитория). - -```bash -# Slot A (0x60040000) — любой из файлов таблицы выше с адресом Slot A -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/<файл>.bin" - -# Slot Б (0x60240000) — любой из файлов таблицы выше с адресом Slot Б -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/<файл>.bin" - -# Стереть слот целиком (адрес — позиционный аргумент, start+length) -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60040000+0x200000 # Slot A -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ - --sector 0x60240000+0x200000 # Slot Б -``` - ---- - -## 4. CDC — статусы и команды - -Подключиться (см. [../README.md](../README.md)): - -```bash -screen /dev/cu.usbmodemXXXX -``` - -- Живость: `{"type":"cmd","cmd":"ping"}` → `{"type":"pong"}`. -- Статус watchdog + счётчика: `{"type":"cmd","cmd":"wdog"}` → - `{"type":"wdog","armed":true,"timeout_s":10,"recovered":,"reset_count":,"threshold":3}`. - `reset_count` — растущий индикатор прогресса к порогу; главный источник диагностики для - сценариев 2/3 (растёт после каждого WDOG-сброса подряд, обнуляется на POR/новой установке/фолбэке). -- Статусы (`{"type":"status","state":"..."}`): `waiting_for_sd`, `installing`, `update_skipped` - (уже знакомы по Фазе 3) + новый **`recovery_mode`** — эмитится вместо `waiting_for_sd`, пока плата - в recovery-состоянии. - ---- - -## 5. Жест BTN_2 (ручной вход в recovery) - -**Держать `BSP_BUTTON_2` нажатой в момент подачи питания** — считывается один раз, сразу после -`bsp_button_init()` (`main.c`), до всей SD-логики, и защёлкивается на сессию — тот же приём, что -`BSP_BUTTON_1` в Фазе 3. Приоритет над `BSP_BUTTON_1` и над обычным boot-путём разрешается внутри -`recovery_decide()` (проверяется первым, независимо от счётчика попыток и состояния слотов). - ---- - -## 6. Индикация recovery-режима - -- **LED**: `LED_HEARTBEAT` и `LED_APP` мигают **синхронно, 100 мс вкл / 100 мс выкл** — чётко - отличается на глаз от обычного heartbeat (50/450 мс, только `LED_HEARTBEAT`) и от app-паттернов - (500/250 мс, только `LED_APP`, см. `stub_a`/`stub_b`). -- **CDC**: `status: recovery_mode` вместо `waiting_for_sd`, повторяется каждые ~1.5 c (тот же период, - что и обычный пере-скан SD). - ---- - -## 7. Чек-лист сценариев - -Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись/установка не ресетят -автоматически). Все тайминги — ориентировочные (наблюдать глазами/секундомером, точная синхронизация -не нужна). - -### Сценарий 1 — Класс A: свежий образ никогда не подтверждается, виснет сразу - -**Подготовка:** -```bash -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 --sector 0x60240000+0x200000 # Slot Б пуст -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_hang_class_a.bin" -``` - -**Ожидаемо:** -1. Power on → bootloader выбирает Slot A (единственный валидный) → прыжок → `LED_APP` **не - загорается вообще** (зависание — до `bsp_led_init()`'s первого toggle в цикле, LED остаётся в - выключенном состоянии init'а). -2. Через ~10 c — аппаратный WDOG-сброс. -3. Bootloader стартует заново; `boot_go()` видит `copy_done=SET` (уже выставлен предыдущим выбором), - `image_ok` НЕ установлен (никогда не подтверждался) → штатный MCUboot-revert → **Slot A стирается - автоматически**. -4. Плата остаётся в обычном цикле ожидания: `LED_HEARTBEAT` — обычный heartbeat (50/450 мс), **не** - recovery-паттерн; CDC `ping`→`pong` живой; `status: waiting_for_sd`. -5. Запросить `{"type":"cmd","cmd":"wdog"}` — `"recovered":true` (последний сброс был по watchdog). - -**Это и есть регрессионный тест находки** (см. PLAN.md, "Найден и исправлен баг: счётчик рос и на -пустой плате") — без фикса плата вместо шага 4 ушла бы в `recovery_mode` через несколько секунд -(накопленный `reset_count`), хотя ей совершенно не о чем сигнализировать: обе полки пусты, это -штатное ожидание первой установки. - ---- - -### Сценарий 2 — Класс B, есть фолбэк: подтверждённый образ виснет в рантайме - -**Подготовка** (см. предупреждение о версиях в §1 — порядок важен): -```bash -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin" -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_confirm_hang_class_b.bin" -``` - -**Ожидаемо** (один длинный power on, ничего трогать не нужно): -1. Bootloader выбирает Slot Б (v2.0.0 > v1.0.0 — активный). `LED_APP` мигает ~2/сек первые ~6 c - (видимое подтверждение "образ жив и уже подтверждён" — `CONFIRM_MODE=0` отработал почти сразу). -2. `LED_APP` застывает (после ~6 c) — зависание после confirm. Через ~10 c от последнего кормления — - WDOG-сброс. -3. Bootloader стартует заново. `image_ok` УЖЕ был `SET` до зависания (confirm состоялся) — штатный - MCUboot-revert НЕ срабатывает (это не Класс A). Slot Б снова валиден и снова активен (та же - версия) → снова выбирается → снова виснет → снова WDOG-сброс. Счётчик (`SRC_GPR3`, переживает - тёплые/watchdog-сбросы) растёт: 1, 2, 3. -4. На **третьем** сбросе подряд (`reset_count == threshold == 3`) — `recovery_decide()` находит - фолбэк (Slot A, валиден) → **Slot Б стирается**, счётчик обнуляется, `boot_go()` выбирает Slot A → - `LED_APP` начинает мигать ~1/сек (Slot A, здоров, никогда не виснет) **и остаётся так постоянно**. -5. Наблюдать по CDC (подключиться в любой момент до/во время цикла): `{"cmd":"wdog"}` → - `reset_count` растёт 1→2→3, затем после фолбэка возвращается к состоянию нормальной работы - (счётчик обнулён — следующий запрос покажет `reset_count:0`, если плата уже перезагрузилась в - рамках здорового Slot A и что-то там его обнулило явно — иначе останется как есть до следующего - события; для стаба, в отличие от tft_app, health-mark/confirm уже случились в Slot Б, Slot A — - собственный образ, его confirm/health-mark произойдут в СВОЁМ прогоне при следующем прыжке в него). -6. Общее время до фолбэка — ориентировочно 3 × (~6 c мигания + ~10 c до сброса) ≈ **45-50 c**. Не - вмешиваться, просто наблюдать. - ---- - -### Сценарий 3 — Класс B, нет фолбэка: откатываться некуда - -**Подготовка:** -```bash -uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 --sector 0x60040000+0x200000 # Slot A пуст -uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ - --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_confirm_hang_class_b.bin" -``` - -**Ожидаемо:** то же, что сценарий 2, шаги 1-3 (Slot Б виснет, WDOG-сбросы, счётчик растёт до 3) — но -на третьем сбросе `recovery_decide()` НЕ находит валидного фолбэка (Slot A пуст) → -**`RECOVERY_ENTER_RECOVERY_MODE`**: -- Прыжок подавлен (Slot Б НЕ стирается — «на диске никогда не бывает нуля рабочих слотов», хотя он - и зависал). -- `LED_HEARTBEAT`+`LED_APP` — recovery-паттерн (синхронно, 100/100 мс). -- CDC: `status: recovery_mode`, повторяется каждые ~1.5 c. -- Плата **не циклится дальше** — остаётся в этом состоянии бессрочно, ждёт SD (ослабленный gate) - или BTN_2 (см. сценарий 4). - -```bash -5:55.694 -> {"type":"status","state":"recovery_mode"} -14:35:57.932 -> {"type":"status","state":"recovery_mode"} -14:35:59.128 -> `{"type":"cmd","cmd":"wdog"}` - -14:36:00.170 -> {"type":"wdog","armed":true,"timeout_s":10,"recovered":true,"reset_count":3,"threshold":3} -14:36:00.170 -> {"type":"status","state":"recovery_mode"} -14:36:02.408 -> {"type":"status","state":"recovery_mode"} -14:36:04.646 -> {"type":"status","state":"recovery_mode"} - -# Перезапуск питания -5:55.694 -> {"type":"status","state":"recovery_mode"} -14:35:57.932 -> {"type":"status","state":"recovery_mode"} -14:35:59.128 -> `{"type":"cmd","cmd":"wdog"}` - -14:36:00.170 -> {"type":"wdog","armed":true,"timeout_s":10,"recovered":true,"reset_count":3,"threshold":3} -14:36:00.170 -> {"type":"status","state":"recovery_mode"} -14:36:02.408 -> {"type":"status","state":"recovery_mode"} -14:36:04.646 -> {"type":"status","state":"recovery_mode"} - - -``` - - - ---- - -### Сценарий 4 — Recovery по BTN_2 (ручной вход) - -**Подготовка:** валидный здоровый образ в любом слоте (например, `stub_a_v1_confirmed.bin` → Slot A, -Slot Б пуст) — специально не пустая плата, чтобы подтвердить: BTN_2 подавляет загрузку ДАЖЕ -валидного образа. - -> ⚠️ **Recovery всегда ставит в Slot A (см. update_policy.h) — `TFT_APP.BIN` ОБЯЗАН быть слинкован -> под адрес Slot A.** Использовать **`stub_a_v1_confirmed.bin`**, переименованный — **НЕ** -> `stub_b_v2_confirmed.bin` (тот линкован под Slot Б, `0x60240000`; физически положенный в Slot A он -> пройдёт крипто-гейт — подпись валидна для содержимого файла независимо от адреса — но исполняться -> будет с чужими абсолютными адресами: тот же класс проблемы, что и в Фазе 3 §5, только теперь для -> recovery-пути). Симптом при ошибке: CDC покажет `installing`, затем USB CDC-порт пропадёт (прыжок -> в нерабочий код), и через несколько автоматических WDOG-циклов плата САМА уйдёт в `recovery_mode` -> — без удержания кнопки, только потому что "успешно установленный" Slot A на самом деле не работает -> и продолжает и продолжает выбираться, копя `reset_count`. - -**Ожидаемо:** -1. **Удерживая `BSP_BUTTON_2`**, подать питание → recovery-режим немедленно (LED-паттерн, CDC - `recovery_mode`), **без попытки прыжка** в валидный Slot A — приоритет BTN_2 подтверждён. -2. Не отпуская последствия (в любой момент, кнопку уже можно отпустить — она сэмплируется один раз - на старте) вставить SD-карту с `TFT_APP.BIN` = **`stub_a_v1_confirmed.bin`**, переименованный (см. - предупреждение выше — не `stub_b_v2_confirmed.bin`). -3. В течение ~1.5 c (следующий пере-скан) — CDC: `installing` → оба слота стираются, новый образ - ставится в Slot A → **авто-прыжок**, `LED_APP` начинает мигать ~1/сек (паттерн `stub_a`). - ---- - -### Сценарий 5 — Транзиент (per-session семантика счётчика) - -**Подготовка:** сразу после сценария 3 (плата в recovery, `reset_count` было 3 на момент входа). - -**Действие:** power cycle БЕЗ удержания BTN_2 (обычное выключение/включение). - -**Ожидаемо:** POR обнуляет счётчик (`bsp_boot_state_init()` детектит `IPP_RESET_B`, чистит -`SRC->SRSR`, зовёт `bsp_boot_attempt_reset()`) → Slot Б (всё ещё физически там же, всё ещё виснет — -ничего не стёрлось в сценарии 3) **пробуется заново** — bootloader НЕ помнит о прошлой серии сбросов -как о чём-то, что сразу требует recovery. Наблюдать: `LED_APP` снова мигает ~2/сек первые ~6 c (Slot -Б снова выбран и снова "жив"), затем весь цикл сценария 2/3 повторяется с нуля (счётчик 1, 2, 3...). -Это подтверждает **per-session** семантику (Вариант 1 из решений Фазы 6: «забанен» = «счётчик ≥ -порога», не отдельный флеш-маркер) — если зависание было транзиентным, POR всегда даёт новый шанс. - ---- - -### Сценарий 6 — Регрессия: не мешает норме - -Прогнать чек-лист Фазы 3 (§8 в [HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md)) -как есть, **и дополнительно**: -- Сценарий 1 этого документа (выше) — сам по себе уже прямой регрессионный тест на находку - «счётчик рос и на пустой плате». -- Оставить чистую плату (оба слота пусты, без SD) в цикле ожидания на 2+ минуты — `reset_count` - должен оставаться `0` бессрочно (запросить `{"cmd":"wdog"}` в начале и в конце интервала, сверить). -- Прогнать сценарий 1 Фазы 3 (чистая плата + валидный образ на SD, авто-установка) — убедиться, что - установка проходит и прыгает штатно, `reset_count` после первого успешного прыжка — `0` или `1` - (инкремент на первую же попытку прыжка в свежеустановленный образ — ожидаемо, не баг). - ---- - -## 8. Итоговая таблица - -| № | Сценарий | Ожидаемый результат | -|---|---|---| -| 1 | Класс A: никогда не confirm, виснет сразу | WDOG-сброс → штатный MCUboot revert → стирание → обычное ожидание (НЕ recovery) | -| 2 | Класс B, есть фолбэк | 3 WDOG-сброса → зависший слот стёрт → фолбэк выбран и стабилен | -| 3 | Класс B, нет фолбэка | 3 WDOG-сброса → recovery-режим (LED-паттерн, CDC `recovery_mode`), не циклится дальше | -| 4 | Recovery по BTN_2 | Подавленный прыжок → recovery → SD найдена → оба слота стёрты → установка → авто-прыжок | -| 5 | Транзиент (per-session) | POR после recovery без BTN_2 → счётчик обнулён → зависший слот пробуется заново | -| 6 | Регрессия | Чек-лист Фазы 3 + пустая плата 2+ мин без ложного recovery | diff --git a/just/build.just b/just/build.just index b11a1fd..0707da0 100644 --- a/just/build.just +++ b/just/build.just @@ -232,7 +232,7 @@ hab-verify project="firmware_test" type="release": exit 1 ;; esac OUT="/tmp/hab_parse_{{ project }}_{{ type }}.yaml" - cd "{{ TOOLS_DIR }}" && uv run nxpimage hab parse -b "${BIN}" -o "${OUT}" + cd "{{ TOOLS_DIR }}" && uv run nxpimage hab parse -f mimxrt1050 -b "${BIN}" -o "${OUT}" echo " ✅ ${OUT}" grep -E "(entry|csf|tag)" "${OUT}" || true diff --git a/just/host.just b/just/host.just index a80b9bf..2e851ca 100644 --- a/just/host.just +++ b/just/host.just @@ -604,6 +604,16 @@ package-tui: echo " Бандл TUI без Debug-образа нерабочий (см. README/DEV_ARCH.md)." exit 1 fi + # Flasher.PRODUCTION (Фаза 5) резолвит bootloader ЖЁСТКО из Release, без + # фоллбэка на Debug (см. flasher.py) — в отличие от firmware_test выше, + # здесь недостаточно "хоть какой-то hab нашёлся", нужен именно этот файл, + # иначе «Серийная прошивка» в готовом бандле молча падает в рантайме. + if [[ ! -f "{{ _prod_build_dir }}/Release/bootloader_hab.bin" ]]; then + echo " ❌ Не найден {{ _prod_build_dir }}/Release/bootloader_hab.bin — соберите" + echo " заранее (just build::hab-bootloader-release)." + echo " Серийная прошивка в бандле TUI без него не работает." + exit 1 + fi VERSION=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"(.+)".*/\1/') case "$(uname -s)" in diff --git a/tools/host/README.md b/tools/host/README.md index 1452af1..4ea9ba7 100644 --- a/tools/host/README.md +++ b/tools/host/README.md @@ -17,7 +17,7 @@ tools/host/ ├── flash_swd.py — прошивка через SWD: FCB + HAB → pyOCD → Flash ├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип; │ service-tui генерирует такие же временно, на лету — -│ см. tools/service_tui/DEV_ARCH.md, §8) +│ см. tools/service_tui/docs/ARCHITECTURE.md, §8) ├── dcd/ │ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP) │ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P) @@ -32,7 +32,7 @@ tools/host/ > Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся > в репозитории — пересоздавать не нужно. `w25q128`/`w25q512` — единственные > два варианта в реальном использовании (64 и 256 сведены к ним же, см. -> `tools/service_tui/DEV_ARCH.md`, §8.2); `w25q64_fdcb.bin` пока не подключён +> `tools/service_tui/docs/ARCHITECTURE.md`, §8.2); `w25q64_fdcb.bin` пока не подключён > нигде — оставлен про запас. --- @@ -44,7 +44,7 @@ tools/host/ Прошивка сторонних/легаси бинарников с нестандартной памятью (явный FCB, без auto-config) — через `service-tui` (`tools/service_tui/`), не напрямую -через `flash_usb.py` из терминала. Детали конвейера — `tools/service_tui/DEV_ARCH.md`, §8. +через `flash_usb.py` из терминала. Детали конвейера — `tools/service_tui/docs/ARCHITECTURE.md`, §8. --- diff --git a/tools/host/hab/hab_bootloader_release.yaml b/tools/host/hab/hab_bootloader_release.yaml index 217115d..a45e12a 100644 --- a/tools/host/hab/hab_bootloader_release.yaml +++ b/tools/host/hab/hab_bootloader_release.yaml @@ -1,11 +1,16 @@ # ============================================================================= -# HAB Container — firmware/bootloader -# Расположение: tools/host/hab/hab_bootloader.yaml +# HAB Container — firmware/bootloader (Release, ПОДПИСАННЫЙ — flags=0x08) +# Расположение: tools/host/hab/hab_bootloader_release.yaml # +# Подпись — HAB4 NOCAK (fast authentication, один ключ и на CSF, и на данные), +# ключ tools/host/hab/keys/ — ТЕСТОВЫЙ, не production (см. keys/README.md и +# docs/bootloader/HAB_GUIDE.md §8). Настоящий production-ключ — отдельная +# SRK-церемония, ключ хранится вне репозитория (HSM/vault), в этот файл не +# попадает. # ============================================================================= options: - flags: 0x00 + flags: 0x08 startAddress: 0x60000000 ivtOffset: 0x1000 initialLoadSize: 0x2000 @@ -14,4 +19,21 @@ options: inputImageFile: "../../../build/Release/bootloader.bin" -sections: [] \ No newline at end of file +sections: + - Header: + Header_Version: "4.2" + Header_HashAlgorithm: sha256 + Header_Engine: ANY + Header_EngineConfiguration: 0 + Header_CertificateFormat: x509 + Header_SignatureFormat: CMS + - InstallNOCAK: + InstallNOCAK_File: keys/bootloader_test_hab_crt.pem + InstallNOCAK_CertificateFormat: x509 + - AuthenticateCSF: + Signer: "type=file;file_path=keys/bootloader_test_hab_key.pem" + - AuthenticateData: + AuthenticateData_VerificationIndex: 0 + AuthenticateData_Engine: ANY + AuthenticateData_EngineConfiguration: 0 + Signer: "type=file;file_path=keys/bootloader_test_hab_key.pem" diff --git a/firmware/bootloader/PLAN.md b/tools/host/hab/keys/PLAN.md similarity index 93% rename from firmware/bootloader/PLAN.md rename to tools/host/hab/keys/PLAN.md index 82cf90f..937457e 100644 --- a/firmware/bootloader/PLAN.md +++ b/tools/host/hab/keys/PLAN.md @@ -9,7 +9,7 @@ | 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) | | 3 — SD-путь установки | ✅ полностью верифицирована (2026-07-13) | все 5 сценариев + раунд 4 (консолидация детекта на единый PRSSTAT + ранний сэмпл кнопки) + **аппаратный watchdog** (`bsp/wdog`, см. ниже) пройдены на реальной плате. По пути найден и исправлен баг чек-листа сценария 1 (стабы не PIC, см. ниже) — не регресс кода. См. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md) | | 4 — SDRAM/W25Q smoke-test + LED-паттерны | ✅ полностью верифицирована (2026-07-20) | `bsp_sdram_configure()` (C-порт DCD) + `qspi_info` (JEDEC → чип/ёмкость) + словарь LED-паттернов. Host-тесты 16/16 (без изменений — вся новая логика аппаратная, host-тестировать нечего). Аппаратная верификация: SDRAM 4-фазный dev-тест (`sdram_test`) все фазы green; сценарии LED 1-5 из чек-листа пройдены (6 сознательно не гонялся — реальная проверка smoke-тестов идёт через service-tui сразу после прошивки, не через синтетический чек-лист). По пути найдено/исправлено 3 бага — детали ниже. Чек-лист — [test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md](test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md), словарь для сервисных инженеров — [docs/bootloader/LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md) | -| 5 — HAB Release + service-tui | не начата | | +| 5 — HAB Release + service-tui | ✅ полностью верифицирована (2026-07-20) | Подписанный HAB Release (`flags=0x08`, тестовый NOCAK-ключ), `BootloaderClient` + Тир-0/Тир-1 верификация в service-tui, production-путь сужен до bootloader-only (сценарий A). Аппаратно: CSF на чипе подтверждён напрямую через SWD (`csf`-поле IVT ненулевое), полный UI-цикл (прошивка → верификация → отчёт) пройден на реальной плате. Детали ниже. | | 6 — Устойчивость и восстановление (recovery) | ✅ полностью верифицирована (2026-07-14) | 6a (счётчик `SRC_GPR3` + фолбэк) + 6b (recovery-режим, BTN_2, ослабленный gate) + стенд `test_stub` реализованы; host-тесты 16/16, ARM+HAB зелёные. Все 6 сценариев чек-листа пройдены на реальной плате; по пути найдено/исправлено 4 бага (2 в коде, 2 в стенде/чек-листе — детали ниже). 6c (контракт tft_app) — документация под будущий план. Чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE6.md](test_stub/HARDWARE_VERIFICATION_PHASE6.md) | ## Контекст @@ -815,6 +815,79 @@ SysTick, на котором целиком держится `bsp_tick_get_ms()` **Верификация**: чистая плата → `just host::flash-production`-путь для bootloader (или его bootloader-only подмножество) → service-tui показывает "bootloader alive" на основе реального ping/version с платы. +### ✅ Реализовано (2026-07-20): подписанный HAB Release + Тир-0/Тир-1 верификация в service-tui + +**HAB Release — реально подписанный (`flags=0x08`), схема HAB4 NOCAK.** Полной SRK-церемонии в проекте +ещё нет (см. решение по Фазе 2 и `docs/mimxrt1052/UPDATE_FLOW.md`) — вместо неё сгенерирован тестовый +RSA-2048 ключ (тот же принцип, что и у тестового ключа MCUboot), вендорен в +[tools/host/hab/keys/](../../tools/host/hab/keys/) с явной пометкой "НЕ production". Это ровно стадия +"Предсерийные образцы" (HAB Open + Signed) из жизненного цикла в +[docs/bootloader/HAB_GUIDE.md §8](../../docs/bootloader/HAB_GUIDE.md). Полный разбор команд CSF-секции +(`Header`/`InstallNOCAK`/`AuthenticateCSF`/`AuthenticateData`) и чем NOCAK отличается от полной +production-схемы с SRK-таблицей — [HAB_GUIDE.md §5.1](../../docs/bootloader/HAB_GUIDE.md). По пути +найден и исправлен баг самого рецепта `just build::hab-verify` — `nxpimage hab parse` требовал `-f +mimxrt1050`, без него падал ещё до моих изменений (не регресс Фазы 5). + +**Найденный и исправленный баг: production мог тихо взять unsigned Debug-образ.** `Flasher.PRODUCTION` +резолвил `bootloader_hab.bin` через `_FIRMWARE_BUILD_TYPE` — ту же переменную окружения, что переключает +Debug/Release **только для диагностической прошивки firmware_test** (дефолт `"Debug"`, см. +`tools/service_tui/README.md` §"Конфигурация"). Без явного `FIRMWARE_BUILD_TYPE=Release` в окружении +серийная прошивка залила бы `build/Debug/bootloader_hab.bin` — unsigned (`flags=0x00`, Debug HAB-конфиг +сознательно не трогали). Исправлено: production-путь резолвит `Release` жёстко, в обход переменной +окружения. + +**Найденный и исправленный архитектурный баг: production затирал только что записанный bootloader.** +Существовавший `Flasher.PRODUCTION` шил bootloader И app **по одному и тому же адресу** `FLASH_BASE` +(0x60000000) — второй шаг стирал первый. Наследие монолитной пре-bootloader эпохи, для Direct-XIP +в принципе неверно (tft_app должен идти в Slot A/Б, другим механизмом подписи — imgtool, не HAB). +tft_app ещё не реализован, поэтому в рамках Фазы 5 **production сужен до сценария A** (только +загрузчик) — правильный бандл (сценарий B: bootloader HAB @0x60000000 + подписанный imgtool'ом образ +@Slot A) явно оставлен как будущая работа вместе с реальным tft_app, см. +[UPDATE_FLOW.md §5, §7](../../docs/mimxrt1052/UPDATE_FLOW.md). + +**Тир-0 (readback-верификация записи) — включена по умолчанию для ЛЮБОЙ прошивки через service-tui**, +не только production. `flash_backend.flash()` после `write_memory`, до `reset`, читает записанный +диапазон обратно и сверяет sha256 с исходным образом — ловит silent-corruption, не пойманную кодом +статуса самой write-команды. Логическая ошибка (`FlashVerifyError`, `connection_lost=False`) — плата +остаётся на экране с сообщением, не уходит на WaitingScreen. Параметр `verify_readback` есть, но +дефолт `True` везде — согласовано, readback дёшев и полезен одинаково для firmware_test/production/custom. + +**Тир-1 (живая проверка загрузчика по CDC) — по чек-боксу «Верификация», OFF по умолчанию.** Новый +[BootloaderClient](../../tools/service_tui/app/bootloader_client.py) (тонкий подкласс `FirmwareClient` — +общий JSON-lines протокол и один VID:PID с firmware_test, намеренно, см. контекст плана выше) добавляет +`get_smoke_status()`/`get_qspi_info()` поверх унаследованных `ping()`/`get_version()`. Новый +[VerifyScreen](../../tools/service_tui/app/screens/verify.py): промпт смены `BOOT_MOD_1 → GND → Reset` +(тот же ручной шаг, что и в `PostFlashScreen`, — SDP/Flashloader не может сам перезапустить плату в +обычный режим) → поллинг CDC (до 45с) → отчёт по 3 полям (версия/SDRAM smoke/QSPI-чип). `None` от +клиента (нет ответа) показывается как ⚠, не как ❌ — это разные вещи ("неизвестно" и "провал"). +Чек-бокс виден только при выбранном production-радио, «липкий» на весь запуск TUI (как остальные +пресеты `FlashScreen`). + +**Смоделированные и обсуждённые trade-off'ы (решения, не пересматриваются в рамках этой фазы):** +- Двухуровневая верификация (Тир-0 всегда / Тир-1 по чек-боксу) — вместо единого включай/выключай: + Тир-0 бесплатен по действиям оператора, Тир-1 стоит одного ручного тоггла BOOT_MOD на плату и + раздражает при массовой заливке в кучу (сценарий A). +- Только сценарий A в этой фазе — сценарий B (бандл bootloader+tft_app) отложен до реального tft_app, + подписанная заглушка не даёт проверить настоящий dual-link (см. предупреждение в UPDATE_FLOW.md §1 + про случайную позиционную терпимость `test_stub`). + +**Не исправлено, сознательно оставлено (известное ограничение, не блокирует эту фазу):** `WaitingScreen` +любой обнаруженный CDC ведёт в `DiagScreen`, который ждёт firmware_test-протокол (`list_tests` и т.п.) — +у загрузчика (тот же VID:PID) их нет. В сценарии A плата снимается со стенда сразу после прошивки, риск +не проявляется на практике; актуально станет при появлении сценария B или при повторном подключении +только что верифицированного загрузчика без последующего reset в SD-режим. + +**Аппаратная верификация.** Полный UI-цикл (регрессия существующих потоков firmware_test/custom/erase → +Тир-0 незаметно проходит на обычных прошивках → production OFF грузит только bootloader → production ON +→ `VerifyScreen` → живой отчёт pass/pass/pass → `WaitingScreen`; плюс отдельно кнопка «Пропустить» и +таймаут-ветка; плюс sticky-пресет между платами) пройден на реальной плате. Отдельно, независимо от +service-tui — подпись подтверждена прямым чтением IVT с чипа через SWD +(`pyocd commander --target mimxrt1050_quadspi -c "read32 0x60001000 32"`): поле `csf` (`0x60001018`) — +`0x6000f000`, совпадает с локальной проверкой `nxpimage hab parse` из Шага 1. + +**Host-тесты**: `uv run pytest tests/` (`tools/service_tui`) — **76/76** (было 68; +8 `BootloaderClient`, +обновлены/добавлены тесты Тир-0 в `test_flash_backend.py`). + --- ## Фаза 6 — Устойчивость и восстановление (recovery) diff --git a/tools/host/hab/keys/README.md b/tools/host/hab/keys/README.md new file mode 100644 index 0000000..04fa53e --- /dev/null +++ b/tools/host/hab/keys/README.md @@ -0,0 +1,42 @@ +# HAB test key — dev-test-only + +`bootloader_test_hab_key.pem` / `bootloader_test_hab_crt.pem` — самоподписанный +RSA-2048 ключ, сгенерированный для Фазы 5 (`firmware/bootloader/PLAN.md`), +чтобы `hab_bootloader_release.yaml` реально собирал **подписанный** HAB-образ +(`flags=0x08`, схема HAB4 NOCAK — один ключ на CSF и на данные) вместо +unsigned-заглушки. + +Тот же принцип, что и у тестового ключа MCUboot +(`sdk/middleware/mcuboot_opensource/root-ec-p256.pem`): +ключ публичный, не секрет, годится только чтобы механизм подписи +проверялся end-to-end на HAB Open чипе (который не отвергает образ даже при +несовпадении подписи с fuses). + +**Для серийного производства этот ключ использовать нельзя.** Реальный +production-ключ — отдельная SRK-церемония (генерация вне этого репозитория, +приватная часть — в HSM/vault, публичный хэш — в OTP fuses при переводе чипа +в HAB Closed). Общая теория — [docs/bootloader/HAB_GUIDE.md](../../../../docs/bootloader/HAB_GUIDE.md) +§5, §8; конкретный план с командами (`nxpcrypto pki-tree hab` и далее) — +[firmware/bootloader/SIGNING_CEREMONY.md](../../../../firmware/bootloader/SIGNING_CEREMONY.md). + +## Как эти файлы используются в `hab_bootloader_release.yaml` + +- `bootloader_test_hab_crt.pem` (сертификат, публичный) — ставится в key store + HAB командой `InstallNOCAK`. +- `bootloader_test_hab_key.pem` (приватный ключ) — используется на хосте, + во время сборки, чтобы подписать CSF (`AuthenticateCSF`) и сам образ + (`AuthenticateData`). + +Подробный разбор, что делает каждая команда CSF-секции (`Header`/`InstallNOCAK`/ +`AuthenticateCSF`/`AuthenticateData`) и чем NOCAK отличается от полной +production-схемы с SRK-таблицей — [docs/bootloader/HAB_GUIDE.md](../../../../docs/bootloader/HAB_GUIDE.md) +§5.1. + +Сгенерирован: + +```bash +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 7300 \ + -keyout bootloader_test_hab_key.pem \ + -out bootloader_test_hab_crt.pem \ + -subj "/CN=TFT bootloader TEST HAB key - NOT FOR PRODUCTION/O=dev-test-only" +``` diff --git a/tools/host/hab/keys/SIGNING_CEREMONY.md b/tools/host/hab/keys/SIGNING_CEREMONY.md new file mode 100644 index 0000000..26d592c --- /dev/null +++ b/tools/host/hab/keys/SIGNING_CEREMONY.md @@ -0,0 +1,234 @@ +# Церемония production-подписи: HAB (bootloader) + MCUboot (tft_app) + +**Статус на момент написания (2026-07-20): оба механизма в dev/test-режиме.** Этот документ — план +на будущее, не инструкция для выполнения сегодня. Триггер к выполнению — первая готовая к реальной +поставке версия `firmware/tft_app` (не тестовая заглушка `test_stub`). + +Ничего в этом документе не меняет код/конфиги сегодняшнего dev-режима. Когда придёт время — этот файл +и есть план работы; по мере исполнения имеет смысл дописывать в него реальные результаты (по образцу +`### ✅ Реализовано` в `PLAN.md`). + +--- + +## 1. Два независимых, но связанных механизма + +| | **HAB** (защищает bootloader) | **MCUboot/bootutil** (защищает tft_app) | +|---|---|---| +| Кто проверяет | BootROM (кремний, неизменяем) | Сам bootloader (код в `mcuboot_port/`) | +| Что проверяется | `bootloader_hab.bin` целиком | Образы в Slot A/Б | +| Алгоритм сегодня (test) | RSA-2048, схема NOCAK (1 ключ) | ECDSA P-256 (TinyCrypt) | +| Ключ сегодня | `tools/host/hab/keys/` (тестовый, см. README там) | `sdk/middleware/mcuboot_opensource/root-ec-p256.pem` (тестовый, публичный, известный всем — sample-ключ самого MCUboot) | +| Где живёт публичная часть | В образе (сертификат в CSF) + (в production) хэш во fuses чипа | Впечён в C-массив в самом bootloader (`mcuboot_port/keys/*_pub.c`) | +| Где живёт приватная часть | Вне репозитория (см. §3 ниже) | Вне репозитория (см. §2 ниже) | +| Подробнее | `docs/bootloader/HAB_GUIDE.md` §4, §5, §5.1, §8 | `PLAN.md` Фаза 2 (раздел "Решения, принятые в обсуждении Фазы 2") | + +**Ключевая зависимость, определяющая порядок действий**: публичный ключ MCUboot зашит в *исходный код +bootloader* — значит, он часть `bootloader.bin`, которую подписывает HAB. Если HAB не защищает +bootloader реально (test-ключ, HAB Open) — MCUboot-ключ внутри него можно подменить, подсунув свой +bootloader с чужим "доверенным" ключом. **Настоящая безопасность MCUboot-подписи существует только +после того, как реальна HAB-подпись.** Поэтому порядок ниже — не произвольный. + +--- + +## 2. Церемония A — production-ключ MCUboot (для tft_app) + +### 2.1 Генерация (офлайн, не в этом репозитории) + +```bash +# На отдельной машине/носителе, НЕ в рабочей копии репозитория +uv run --with cryptography --with intelhex --with click --with cbor2 --with pyyaml python3 \ + sdk/middleware/mcuboot_opensource/scripts/imgtool.py keygen \ + -k tft_app_production_ecdsa.pem -t ecdsa-p256 -p +``` + +`-p` — запросит пароль для шифрования приватного ключа (рекомендуется, раз ключ вообще должен куда-то +физически лежать). Итоговый `tft_app_production_ecdsa.pem` — **никогда в git, ни в этот репозиторий, ни +в какой-либо другой**. Хранить в парольном менеджере компании / HSM, аналогично private.pem из HAB +Trust Chain (`HAB_GUIDE.md` §5). + +### 2.2 Публичная часть — впечь в bootloader + +```bash +imgtool.py getpub -k tft_app_production_ecdsa.pem -e lang-c \ + -o firmware/bootloader/mcuboot_port/keys/tft_app_production_ecdsa_pub.c +``` + +В `firmware/bootloader/mcuboot_port/keys.c` заменить `#include "keys/bootloader_test_ecdsa_pub.c"` на +новый файл. Старый тестовый файл можно удалить (в поле ещё нет ни одного образа, подписанного тестовым +ключом, — заменять его не на что). + +> Если понадобится **ротация** ключа уже ПОСЛЕ первого релиза (ключ скомпрометирован, ключ утерян) — +> `bootutil_keys[]` в `keys.c` — массив, а не одиночная переменная: можно зарегистрировать сразу два +> ключа (старый + новый) на переходный период, чтобы уже выпущенные образы продолжали проверяться, +> пока не обновятся все борты в поле. Для *первого* релиза это не нужно — используется один ключ. + +### 2.3 Пересборка и HAB-подпись bootloader с новым ключом + +Обычный `just build::build-bootloader-release` + `hab-bootloader-release` (см. §3) — новый public key +уже часть исходников, попадёт в бинарь автоматически. + +### 2.4 Подпись самого tft_app при релизе + +Тот же `imgtool.py sign`, что уже используется для `test_stub` (`just/build.just`, группа +`mcuboot-stub`, эталонная команда — `imgtool.py sign -k <ключ> -H 0x200 -S 0x200000 --align 1 +--pad-header --pad -v X.Y.Z [--confirm] INFILE OUTFILE`), только `-k` указывает на production-ключ +вместо `root-ec-p256.pem`, и `INFILE` — реальный `tft_app.bin`, не заглушка. + +**Напоминание, не про эту церемонию, но про тот же релизный процесс** (см. +`docs/mimxrt1052/UPDATE_FLOW.md` §3, §5): на каждую версию нужны **два** бинаря — слинкованный под +Slot A и под Slot Б (`__slot_base__` в параметризованном `.ld`), оба подписаны одной командой/ключом, +одной версией. `--confirm` — только для образа, который заливается на завод в основной слот при +пустой второй; полевые обновления через SD confirm сам себя рантаймом (`boot_set_confirmed()`), +подписывать заранее confirmed для них не нужно. + +--- + +## 3. Церемония B — production-ключ HAB (для bootloader) + +Это и есть "SRK-церемония", на которую по всему проекту (`PLAN.md`, `UPDATE_FLOW.md`, +`tools/host/hab/keys/README.md`) стоят ссылки-заглушки. Инструмент — **`nxpcrypto`** (часть spsdk, +уже установлен в `tools/host`, отдельно скачивать NXP CST не нужно — `nxpcrypto pki-tree hab` это его +аналог). + +### 3.1 Решить перед началом (зависит от требований на тот момент, не от кода) + +- **RSA-2048 или RSA-4096?** Сегодняшний test-ключ — 2048 (быстрее проверка на Cortex-M7 без + крипто-ускорителя, см. рекомендацию из Фазы 2 плана для аналогичного выбора под ECDSA). 4096 — + сильнее, требование заказчика/сертификации может обязать. Решать на момент церемонии. +- **Сколько SRK-ключей реально фузить (1–4)?** Рекомендация: **4**, даже если реально подписывать + будет только один. Почему — см. §3.4 ниже, это дешёвая страховка на будущее, которая невозможна + постфактум (fuse пишется один раз). +- **Пароль на приватные ключи PKI-дерева** — да, аналогично MCUboot-ключу. + +### 3.2 Генерация полного PKI-дерева (офлайн) + +```bash +uv run --directory tools/host nxpcrypto pki-tree hab \ + -k rsa2048 -n 4 -ca -d 20 \ + -o /secure/offline/path/hab_pki_2026 -p +``` + +`-ca` — SRK-сертификаты являются CA, под каждым генерируется своя пара CSF/IMG-сертификатов (полная +production-иерархия из `HAB_GUIDE.md` §5, а не сегодняшний NOCAK). `-n 4` — все 4 слота SRK-таблицы. +`-d 20` — срок действия сертификатов (лет); фактическая граница доверия чипа определяется fuse, не +датой сертификата, но разумный запас не помешает. Результат: CA-сертификат, 4×(SRK + CSF + IMG) — +**все приватные ключи из этого дерева — вне репозитория, аналогично MCUboot-ключу.** + +### 3.3 Артефакты для `hab_bootloader_release.yaml` + +```bash +# SRK-таблица — то, что укажет InstallSRK_Table в новом yaml +uv run --directory tools/host nxpcrypto rot export \ + -f mimxrt1050 -k SRK0_sha256_2048_65537_v3_ca_crt.pem -k SRK1_...pem -k SRK2_...pem -k SRK3_...pem \ + -o tools/host/hab/keys/production/srk_table.bin + +# Хэш SRK-таблицы — то, что пойдёт в fuse (§3.5) +uv run --directory tools/host nxpcrypto rot calculate-hash \ + -f mimxrt1050 -k SRK0_...pem -k SRK1_...pem -k SRK2_...pem -k SRK3_...pem \ + -h sha256 -o tools/host/hab/keys/production/srk_hash.bin +``` + +`tools/host/hab/keys/production/` — предлагаемое место для **публичных** артефактов (SRK-таблица, +сертификаты CSF0_1/IMG0_1) — их можно коммитить, это не секрет. Приватные ключи (`*_key.pem` из +PKI-дерева) в этот путь никогда не попадают. + +### 3.4 Новый `sections:` в `hab_bootloader_release.yaml` + +Заменить сегодняшний `InstallNOCAK` на полную цепочку (см. разбор команд в +[HAB_GUIDE.md §5.1](../../docs/bootloader/HAB_GUIDE.md)): + +```yaml +sections: + - Header: {...} # без изменений + - InstallSRK: + InstallSRK_Table: keys/production/srk_table.bin + InstallSRK_SourceIndex: 0 # каким из 4 слотов подписываем СЕГОДНЯ + - InstallCSFK: + InstallCSFK_File: keys/production/CSF0_1_sha256_2048_65537_v3_usr_crt.der + - AuthenticateCSF: + Signer: "type=file;file_path=/secure/offline/path/hab_pki_2026/CSF0_1_..._key.pem;password=..." + - InstallKey: # отдельный IMG-ключ — не обязателен (можно + InstallKey_File: keys/production/IMG0_1_..._crt.der # переиспользовать CSFK и для данных, + InstallKey_VerificationIndex: 0 # как сегодня NOCAK), но разделение + InstallKey_TargetIndex: 2 # CSF/IMG — стандартная рекомендация + - AuthenticateData: # AN12263 (разная ответственность ключей) + AuthenticateData_VerificationIndex: 2 + Signer: "type=file;file_path=/secure/offline/path/hab_pki_2026/IMG0_1_..._key.pem;password=..." +``` + +**Почему НЕ NOCAK для production (в отличие от сегодняшнего test-ключа).** NOCAK — один +неревокируемый ключ; если он скомпрометирован, компрометация окончательна для всех уже выпущенных +чипов. SRK-таблица на 4 ключа + `SRK_REVOKE`-fuses позволяет **отозвать** активный ключ и переключиться +на другой уже зафьюженный SRK без повторной физической фуз-церемонии — вот почему в §3.1 рекомендованы +все 4 слота даже если реально подписывать будет один. День-в-день ротация CSFK/IMG-сертификата (не +SRK) вообще не требует новых fuse — только новую подпись новым сертификатом той же (уже зафьюженной) +SRK-цепочки. + +### 3.5 Сборка, парсинг, аппаратная верификация — БЕЗ fuse + +Ровно тот же процесс, что уже проверен в Фазе 5 с тестовым ключом: + +```bash +just build::hab-bootloader-release +just build::hab-verify bootloader release +``` + +Ожидаемо — `csf`-поле в IVT ненулевое (см. `PLAN.md` Фаза 5 для формата проверки, включая прямое +чтение с чипа через `pyocd commander ... read32 0x60001000 32`). Чип на этом этапе — **HAB Open** +(fuse ещё не тронуты) — тестирование безопасно и полностью обратимо: прошивать/erase/перепрошивать +сколько угодно раз, HAB Open проверяет подпись, но игнорирует ошибку (`HAB_GUIDE.md` §4.2). + +**Прогнать полный чек-лист аппаратной верификации Фазы 5 заново** (тот же список из обсуждения +Фазы 5) — с этим ключом, не с тестовым — до какого-либо намерения трогать fuse. + +### 3.6 ⚠️ Fuse — необратимо, только после полной уверенности + +```bash +# ВАЖНО: адрес OTP-слова SRK_HASH ниже НЕ проверен в рамках этой сессии — обязательно +# свериться с MIMXRT1052 Reference Manual (глава Fusemap / OCOTP) и/или NXP AN12263 §4 +# на момент исполнения, а не доверять этому файлу. Ошибка в адресе — это фуз в СОСЕДНЕЕ +# поле, тоже необратимо. +uv run --directory tools/host blhost -u -- efuse-program-once <ПРОВЕРЕННЫЙ_АДРЕС> +``` + +- Программируется побитово, каждый бит — один раз. Ошибка не устраняется повторной записью. +- Только SRK_HASH — **не** SEC_CONFIG в этот момент. SRK_HASH сам по себе не включает принудительную + проверку (HAB Open по-прежнему игнорирует ошибку подписи) — это промежуточный, наблюдаемый шаг: + можно прошивать заведомо неподписанные/испорченные образы и *наблюдать*, что chip всё равно их + грузит (Open), но теперь есть с чем сверять хэш при будущем переводе в Closed. +- Делать на 1 плате из партии сначала, не на всей партии сразу. + +### 3.7 ⚠️⚠️ Закрытие HAB (`SEC_CONFIG=1`) — отдельное, более позднее решение + +Из `HAB_GUIDE.md` §4.2, §8: необратимо, чип нельзя вернуть в Open. **Это отдельная веха, не часть +одной церemonии с §3.6** — между фузом SRK_HASH и закрытием HAB должно пройти достаточно времени с +подписанным (по-настоящему, этим самым ключом) образом в поле/на стенде, чтобы быть уверенным, что +подпись гарантированно проходит на реальном железе при реальном процессе прошивки, прежде чем убрать +последний путь отступления. + +--- + +## 4. Итоговый порядок действий (сводный чек-лист) + +1. [ ] §2.1 — сгенерировать production-ключ MCUboot, приватную часть — в HSM/vault, не в репозиторий. +2. [ ] §2.2 — публичную часть вписать в `mcuboot_port/keys.c`, удалить тестовый ключ. +3. [ ] §3.1 — решить RSA-размер и число SRK-слотов (рекомендация: 4). +4. [ ] §3.2 — сгенерировать HAB PKI-дерево, приватные ключи — в HSM/vault. +5. [ ] §3.3–3.4 — собрать SRK-таблицу/хэш, переписать `hab_bootloader_release.yaml` (NOCAK → + SRK/CSFK/IMG). +6. [ ] §3.5 — пересобрать bootloader (уже с новым MCUboot-ключом внутри, из шага 2), подписать новым + HAB-ключом, прогнать полный чек-лист аппаратной верификации Фазы 5 на HAB Open чипе. +7. [ ] Подписать первый релиз tft_app production MCUboot-ключом (§2.4), оба слота (A/Б), проверить + end-to-end (сценарий B, отложенный в Фазе 5 до появления tft_app). +8. [ ] §3.6 — зафузить SRK_HASH на одной плате, убедиться, что HAB Open продолжает работать штатно. +9. [ ] Наблюдение/накопление уверенности (срок — отдельное решение, не техническое). +10. [ ] §3.7 — закрытие HAB (`SEC_CONFIG=1`) на производственной линии, начиная с ограниченной партии. + +## 5. Что сознательно не решено в этом документе + +- Точный OTP-адрес SRK_HASH для MIMXRT1052 (см. предупреждение в §3.6). +- RSA-2048 vs 4096, число реально используемых (не только зафьюженных) SRK-ключей. +- Момент закрытия HAB — отдельная веха, не блокирует первый релиз bootloader/tft_app с открытым HAB + (это и есть "Предсерийные образцы" из `HAB_GUIDE.md` §8, легитимная стадия, не временная затычка). +- Формат хранения приватных ключей (конкретный HSM/vault/парольный менеджер) — организационное + решение, не техническое. diff --git a/tools/host/hab/keys/bootloader_test_hab_crt.pem b/tools/host/hab/keys/bootloader_test_hab_crt.pem new file mode 100644 index 0000000..4d10c2e --- /dev/null +++ b/tools/host/hab/keys/bootloader_test_hab_crt.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhzCCAm+gAwIBAgIUAhdVtSPaBziZTJVaTO0TbVlP1ZkwDQYJKoZIhvcNAQEL +BQAwUzE5MDcGA1UEAwwwVEZUIGJvb3Rsb2FkZXIgVEVTVCBIQUIga2V5IC0gTk9U +IEZPUiBQUk9EVUNUSU9OMRYwFAYDVQQKDA1kZXYtdGVzdC1vbmx5MB4XDTI2MDcy +MDA4NTMzMVoXDTQ2MDcxNTA4NTMzMVowUzE5MDcGA1UEAwwwVEZUIGJvb3Rsb2Fk +ZXIgVEVTVCBIQUIga2V5IC0gTk9UIEZPUiBQUk9EVUNUSU9OMRYwFAYDVQQKDA1k +ZXYtdGVzdC1vbmx5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnJvR +E0hwfwvp+X/ixyNmAX2/jQLJI83xISnPwly8n94uBU9CLBtb+eBhpxUknzAg3anl +Gpp2QYKEc6//PnHPcQfWocDlk9bzuMSl1QERQrT7SpTI6sFMdt1A6E1STzKQ4ZUB +1myPd+4JMs5vPoB8+yNolWBxf1c7ug+vvYhUbP6CFX1r64RACttl5uhSeydhjqbe +PRQqiOYujtDvcsXe5ZKAHAXWSbi740b8NRQ3sUG0zZG6Co+mUu9HlWH/3MxRiLce +J7SRr/4HSjDL/0YADpes7YXviM7h4DO6CsySwkgBtgjdJiaKEcyPTHhNkeZVTJ7g +w8PWG8CYwBZUEwHHdwIDAQABo1MwUTAdBgNVHQ4EFgQUYKIoOKr6p7A49PdKAmqK +5MqbsBIwHwYDVR0jBBgwFoAUYKIoOKr6p7A49PdKAmqK5MqbsBIwDwYDVR0TAQH/ +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAESQOmkspbSzmowybi492OOINBZug +6F488aqN/EgVnXlgwL81U10HnZigN04cgviiFaAvsYhE/cU16E+Le0tUzMciMWBk +Tp5Wv55dv95L+DPRV2/9pRY6YyinK3fPdCqQFYsTRxBNm8MVYwIaUhawvb20u36h +ZqQvYRHflKbeyBOAxe3d3DhsaQEowKQJrqord4z3Mos5AGKmk6GB0g7bj9uj1tY6 +L9rWRkWRYCKGj/XARBh9lmL/wjhnkYHF3o1jPQayimHDat09izFB0uw1uQ596Nxc +j4ubVpMYYYHBdZ2JHRtTz7SGAdBIKdEWxCqrynehbxvUIJwu5iaETnvJ0w== +-----END CERTIFICATE----- diff --git a/tools/host/hab/keys/bootloader_test_hab_key.pem b/tools/host/hab/keys/bootloader_test_hab_key.pem new file mode 100644 index 0000000..03b6362 --- /dev/null +++ b/tools/host/hab/keys/bootloader_test_hab_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCcm9ETSHB/C+n5 +f+LHI2YBfb+NAskjzfEhKc/CXLyf3i4FT0IsG1v54GGnFSSfMCDdqeUamnZBgoRz +r/8+cc9xB9ahwOWT1vO4xKXVARFCtPtKlMjqwUx23UDoTVJPMpDhlQHWbI937gky +zm8+gHz7I2iVYHF/Vzu6D6+9iFRs/oIVfWvrhEAK22Xm6FJ7J2GOpt49FCqI5i6O +0O9yxd7lkoAcBdZJuLvjRvw1FDexQbTNkboKj6ZS70eVYf/czFGItx4ntJGv/gdK +MMv/RgAOl6zthe+IzuHgM7oKzJLCSAG2CN0mJooRzI9MeE2R5lVMnuDDw9YbwJjA +FlQTAcd3AgMBAAECggEAJTOeZsY+cu+RlQMcC9IM2S3V4tlWjnrNXONhxvnglNli +dUBup6uYHbN/fwE2wdnc9Mr28WrfzJAEhra4G01Nybvi8GmyH3xgSPPtsLugLghv +6TeOwrVIRLQqRwSXGNkaMXwEA7umGQciiD8hjedXyRCQN2vBik5ekcRIKu3HoFw1 +yOh5rh2WrEgyIZ4WgMdJ/VA4VjN8w5iGEHZwOL2aukxWmyXsmt3ouA7q963NXdtc +snVqSwgI3DlZ1v6eVdeK12G/RDlXI/1HjefVWjVGRuaujVbcB68HkRyaHaW4/R87 +uIper5XfsW4lXDU/vRaRlHIzcGTreC3g0lF8bycdAQKBgQDJw437zPWHG1Mlj3Ey +dxSKdIErF+7/lRmCQgK4SDNs/vEfMQPI+GwIQETIF7mlrA+6sGRXC3Po0wrBRLUp +7NDVERYs60iGetGyrZp7/wWSO7u6PSTW93ZDkH5a3k6Hhj7x5WSGNol6DpzxyWHX +xPDP+340llr31nJTciMrC4mmuQKBgQDGtOQU+iExRmAFxXBp93sdicxqKOW2BI0b +Lpspf5cfrrx2oewWQAZk3t7l3UZ91SqzHJ3JEs2IzwzxZIgtNux6YVIqOzpktPgL +z6OtXof8OchjfpBM8wn2cuVVy++YeXdrgqm+9h27b57L6UK6bqoIKVY3UQ3nADgF +oVDYgkfHrwKBgAPjZ+YVYhnzddvAcu8FCnlMm3yoHmwAhJhVC1Zf1dXg5+aw/CCw +YOybyHeOtX4y3a68XYKr7tTq1Ec7Or6PGMsyZBoDgsdMsKPf4p1HIeVaj1icewxF +gpr0uuqnZB4KgUYKJLDNdkLK601hkygkXHu1ng4HY8No95USGwlmVIXJAoGBALUM +YniegxnDdoArqkZS4JFEWcUsaEiVEwB+UY/ZGhga/MrWtCZ9xZWo38zu9Wh3joVO +AJIRmMYbaT6Ui+MulP7Yp6Wk+4qAvTT9xrOTWzG8cFH/InmKRDyR6VVoEHGL0vxZ +PFKrKuH6TOScL1lwtWnkSBL0vkcIkwLGPcRXyBEvAoGBALs7IYnbEQpsDwZ5jjby +pUeEDKMlbEUSVsCoTrkhOxKfRHpm55mg//jul/KpxONsKbxqDj8V6eMhEOdJ4j6y +6qFfpngT3IogvHx29gx58BdoGdMUbl+KzyD6UTUrqYks7NJFE/0f2QzWq2H9c6lo ++TvhJsEsoSIuit9DeZJTulyc +-----END PRIVATE KEY----- diff --git a/tools/service_tui/README.md b/tools/service_tui/README.md index 99f87f3..a160f22 100644 --- a/tools/service_tui/README.md +++ b/tools/service_tui/README.md @@ -4,7 +4,7 @@ TUI-приложение для диагностики и прошивки пл Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows. > Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков — -> в [DEV_ARCH.md](DEV_ARCH.md). +> в [ARCHITECTURE.md](docs/ARCHITECTURE.md). --- @@ -53,7 +53,8 @@ TUI не пытается восстановить прежнее состоян │ │ │ Выбор загружаемой прошивки │ │ ◉ Диагностическая прошивка (firmware_test) │ -│ ○ Серийная прошивка (bootloader + tft_app) │ +│ ○ Серийная прошивка (загрузчик) │ +│ ○ Верификация (smoke-test, требует BOOT_MOD) │ ← только если «Серийная» │ ○ Другое │ │ Файл (custom_binaries/) │ │ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое» @@ -87,18 +88,61 @@ Python API `spsdk` (без вызова внешних CLI-утилит): 1. `HabImage` (spsdk) — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM") 2. в Flash пишется явный FCB под выбранную память платы (не тот же auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для - W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`) + W25Q256/512 он ненадёжен, см. `docs/ARCHITECTURE.md`) 3. образ прошивается с `0x60001000`, как обычно -**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не -нужно выставлять заново на каждой следующей плате: прошили одну, вынули -USB, вставили следующую такую же — TUI уже подставила прошлый выбор, -остаётся нажать "Загрузить". Сбрасывается только при перезапуске TUI. +**Выбор запоминается на весь запуск TUI** — файл, память платы, DCD и чек-бокс +«Верификация» не нужно выставлять заново на каждой следующей плате: прошили +одну, вынули USB, вставили следующую такую же — TUI уже подставила прошлый +выбор, остаётся нажать "Загрузить". Сбрасывается только при перезапуске TUI. + +**Верификация записи — два независимых уровня.** После записи, перед reset, +TUI **всегда** читает записанный диапазон обратно и сверяет с образом +(silent-corruption ловится автоматически, для любой прошивки — в логе видна +строка «Верификация записи: OK», отдельного экрана не требует). Чек-бокс +«Верификация» у «Серийной прошивки» — это **другой**, более дорогой уровень: +живой smoke-test самого загрузчика по USB CDC (SDRAM + идентификация QSPI-чипа), +требует физической смены `BOOT_MOD` оператором — см. `VerifyScreen` ниже. +OFF по умолчанию, чтобы не мешать массовой заливке партии плат. + +### Живая проверка загрузчика (VerifyScreen) + +Показывается только после успешной **серийной** прошивки при включённом +чек-боксе «Верификация» (иначе — сразу `WaitingScreen`, как раньше). + +```bash +┌────────────────────────────────────────────────────┐ +│ 🔎 Верификация загрузчика │ +│ │ +│ После серийной прошивки плата осталась в режиме │ +│ BootROM. Чтобы проверить загрузчик, переведите её │ +│ в нормальный режим: BOOT_MOD_1 → GND → Reset │ +│ │ +│ ⠋ Плата найдена, проверяю загрузчик... │ +│ │ +│ ✅ Загрузчик отвечает: v0.1.0 │ +│ ✅ SDRAM smoke-test: пройден │ +│ ✅ QSPI-чип: W25Q128 (16 МБ) │ +│ ✅ Верификация пройдена │ +│ │ +│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │ +└────────────────────────────────────────────────────┘ +``` + +Загрузчик и firmware_test используют один и тот же CDC VID:PID (намеренно, +чтобы переиспользовать клиентский код) — но у загрузчика нет `list_tests`, +поэтому эта проверка не идёт через обычный `WaitingScreen`-автодетект +(который вёл бы в `DiagScreen`), а отдельным экраном сразу после прошивки. +«⚠» вместо «❌» на отдельном пункте означает «нет ответа» (таймаут/старая +прошивка без команды), не обязательно провал. Таймаут ожидания BOOT_MOD — 45с, +после — сообщение об этом и переход дальше по кнопке. «Пропустить проверку» +доступна в любой момент ожидания. ### Переход в рабочий режим (PostFlashScreen) Показывается **только** после успешной прошивки `firmware_test` (для -Production/Custom этот шаг не нужен). +Production/Custom этот шаг не нужен — у Production при включённой +верификации свой отдельный экран, см. `VerifyScreen` выше). ```bash ┌────────────────────────────────────────────────────┐ @@ -139,6 +183,7 @@ Production/Custom этот шаг не нужен). ``` Что важно знать: + - **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать все"/"Снять все". - **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена @@ -210,7 +255,7 @@ Production/Custom этот шаг не нужен). нельзя идентифицировать по UID — авто-прошивка по факту детекта без подтверждения оператора убрала бы последний шанс заметить, что в руках не та плата. Массового программирования (несколько плат параллельно) - нет и не планируется в этом виде — см. `docs/DEV_ARCH.md`, §8. + нет и не планируется в этом виде — см. `docs/ARCHITECTURE.md`, §8. - **Циклический прогон тестов** (повторный автозапуск набора без ручного нажатия на каждую итерацию) — отложен на пост-релиз, не входит в текущую версию. @@ -220,11 +265,18 @@ Production/Custom этот шаг не нужен). MicroPython на M5 — агенту нужно время на инициализацию I2C/AW9523/CAN перед готовностью отвечать). WaitingScreen в это время показывает «Плата найдена, подключаемся...» — это штатное поведение, не зависание. - См. `docs/DEV_ARCH.md`, §10. + См. `docs/ARCHITECTURE.md`, §10. - **macOS-бандл не подписан Apple Developer ID и не нотаризован** — при первом запуске Gatekeeper блокирует каждый файл бандла по отдельности. Обход — `xattr -cr` на распакованную папку, см. «Запуск» → «macOS: первый запуск» выше. +- **Загрузчик, оставленный подключённым после `VerifyScreen`, будет + неправильно маршрутизирован.** `WaitingScreen` детектит любой CDC (у + загрузчика и firmware_test один VID:PID) и ведёт в `DiagScreen`, которая + ждёт протокол firmware_test (`list_tests` и т.п.) — у загрузчика его нет. + На практике не мешает: после верификации плата снимается со стенда + (сценарий "bootloader-only, потом массовая заливка SD"), не остаётся + подключённой к TUI. --- @@ -287,7 +339,7 @@ just host::package-tui spsdk Python API (`app/flash_backend.py`), без вызова `tools/host/` ни субпроцессом, ни как-либо иначе. Не нужны ни Zadig, ни установленный Python/uv на машине сервисника. Структура бандла и резолв путей во frozen — -см. [DEV_ARCH.md](docs/DEV_ARCH.md), §14. +см. [ARCHITECTURE.md](docs/ARCHITECTURE.md), §14. #### macOS: первый запуск («Apple could not verify...» на каждый файл) @@ -314,13 +366,13 @@ xattr -cr service-tui-vX.Y.Z-macos/ ## Зависимости -| Пакет | Версия | Назначение | -| --------------- | ------- | -------------------------------------------------------- | -| `textual` | ≥ 0.80 | TUI фреймворк | -| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial | -| `spsdk` | 3.7.0 | прошивка: SDP/McuBoot (Flash, chip erase) + HabImage (сборка кастомных HAB), нативный HID (libusbsio) — без Zadig | -| `python-dotenv` | ≥ 1.0 | загрузка `.env` | -| `pyinstaller` | ≥ 6.0 | сборка standalone-бандла (`just host::package-tui`) | +| Пакет | Версия | Назначение | +| --------------- | ------ | ----------------------------------------------------------------------------------------------------------------- | +| `textual` | ≥ 0.80 | TUI фреймворк | +| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial | +| `spsdk` | 3.7.0 | прошивка: SDP/McuBoot (Flash, chip erase) + HabImage (сборка кастомных HAB), нативный HID (libusbsio) — без Zadig | +| `python-dotenv` | ≥ 1.0 | загрузка `.env` | +| `pyinstaller` | ≥ 6.0 | сборка standalone-бандла (`just host::package-tui`) | **Никакой рантайм-зависимости на `tools/host/` в смысле subprocess-вызовов нет.** Прошивка выполняется in-process через `spsdk` (`app/flash_backend.py`). @@ -328,7 +380,7 @@ xattr -cr service-tui-vX.Y.Z-macos/ `*_fdcb.bin`, `ivt_flashloader.bin`) — они отслеживаются в git, `just host::setup-tools` для запуска TUI из исходников не требуется. `tools/host/ flash_usb.py` — независимый dev-CLI для `just host::flash*`, TUI его не -вызывает (см. [DEV_ARCH.md](docs/DEV_ARCH.md), §1/§8). +вызывает (см. [ARCHITECTURE.md](docs/ARCHITECTURE.md), §1/§8). --- diff --git a/tools/service_tui/app/app.py b/tools/service_tui/app/app.py index 955b2fa..4787fb8 100644 --- a/tools/service_tui/app/app.py +++ b/tools/service_tui/app/app.py @@ -19,7 +19,13 @@ from textual.binding import Binding from .firmware_client import FirmwareClient from .m5_client import M5Client from .models import AppMode, FlashPreset, FlashTarget -from .screens import DiagScreen, FlashScreen, PostFlashScreen, WaitingScreen +from .screens import ( + DiagScreen, + FlashScreen, + PostFlashScreen, + VerifyScreen, + WaitingScreen, +) logger = logging.getLogger(__name__) @@ -82,7 +88,8 @@ class ServiceApp(App): """ После прошивки: - firmware_test + успех → PostFlashScreen (промпт смены BootMode) - - production/custom + успех → WaitingScreen + - production + успех + preset.verify → VerifyScreen (Тир-1, Фаза 5) + - production (без verify) / custom + успех → WaitingScreen - target=None — обрыв USB (watcher в простое ИЛИ backend во время активной операции, см. models.FlashResult, Фаза 4a вариант 2) → WaitingScreen с причиной (конкретный текст, если есть, иначе @@ -102,9 +109,21 @@ class ServiceApp(App): if event.success and event.target == FlashTarget.FIRMWARE_TEST: self.switch_screen(PostFlashScreen()) + elif ( + event.success + and event.target == FlashTarget.PRODUCTION + and event.preset is not None + and event.preset.verify + ): + self.switch_screen(VerifyScreen()) else: self._switch_to_waiting() + @on(VerifyScreen.Done) + def _on_verify_done(self) -> None: + """Оператор завершил/пропустил верификацию загрузчика (Тир-1).""" + self._switch_to_waiting() + @on(PostFlashScreen.Done) def _on_post_flash_done(self) -> None: """Оператор подтвердил смену BootMode или истёк таймаут.""" diff --git a/tools/service_tui/app/app.tcss b/tools/service_tui/app/app.tcss index caaf5cb..1e52229 100644 --- a/tools/service_tui/app/app.tcss +++ b/tools/service_tui/app/app.tcss @@ -123,6 +123,24 @@ AppFrame { margin-right: 1; } +#flash-production-group { + margin-top: 1; + height: auto; +} + +#flash-production-group.hidden { + display: none; +} + +#flash-verify-row { + height: auto; + align: left middle; +} + +#flash-verify-switch { + margin-right: 1; +} + #flash-btn-row { height: auto; margin-top: 1; @@ -201,6 +219,74 @@ AppFrame { width: 100%; height: auto; } + +/* ── VerifyScreen (Тир-1) ───────────────────────────────── */ + +#verify-frame { + align: center middle; +} + +#verify-title { + text-style: bold; + content-align: center middle; + margin-bottom: 2; +} + +#verify-instruction { + border: round $warning; + padding: 1 3; + color: $text; + content-align: center middle; + width: auto; +} + +#verify-status { + margin-top: 2; + content-align: center middle; + width: auto; +} + +#verify-status.verify-pass { + color: $success; + text-style: bold; +} + +#verify-status.verify-fail { + color: $error; + text-style: bold; +} + +#verify-title-row, +#verify-instruction-row, +#verify-status-row { + width: 100%; + height: auto; +} + +#verify-results { + margin-top: 2; + height: auto; + align: center middle; +} + +#verify-results.hidden { + display: none; +} + +.verify-result-line { + width: auto; + content-align: center middle; +} + +#verify-btn-row { + margin-top: 2; + height: auto; + align: center middle; +} + +#verify-btn-done { + margin-right: 2; +} /* ── DiagScreen ─────────────────────────────────────────── */ #diag-frame { diff --git a/tools/service_tui/app/bootloader_client.py b/tools/service_tui/app/bootloader_client.py new file mode 100644 index 0000000..b100176 --- /dev/null +++ b/tools/service_tui/app/bootloader_client.py @@ -0,0 +1,114 @@ +""" +bootloader_client.py — async CDC-клиент загрузчика (Фаза 5). + +Тонкий подкласс FirmwareClient: загрузчик и firmware_test делят один и тот же +JSON-lines v2 протокол и один VID:PID (намеренно, чтобы переиспользовать +клиентский код), поэтому транспорт, ping() и get_version() наследуются как есть. + +Отличие — набор команд. У загрузчика ЕСТЬ smoke_status/qspi_info (Фаза 4: +результаты SDRAM/SEMC smoke-теста и идентификации QSPI-чипа, закэшированные на +раннем старте main), но НЕТ list_tests/run_selected/get_uid — те специфичны для +firmware_test. Поэтому подкласс только ДОБАВЛЯЕТ методы, ничего не убирая +(вызывать унаследованные list_tests() и т.п. на загрузчике просто бессмысленно — +он на них не ответит, вернётся пустой список по таймауту). + +Используется экраном верификации (Тир-1) сразу после серийной прошивки +загрузчика: оператор переводит BOOT_MOD → GND + reset, плата грузится в +загрузчик, поднимает USB CDC, и service-tui читает ping/version/smoke/qspi. +""" + +from __future__ import annotations + +import asyncio +from typing import Callable, Optional + +from .firmware_client import FirmwareClient + +# Загрузчик шлёт status/qspi_info только если результат УЖЕ закэширован (см. +# protocol_send_smoke_status/protocol_send_qspi_info в прошивке — при +# неизвестном результате ответа нет вообще). К моменту подключения по CDC +# smoke обычно уже прогнан в раннем main, но таймаут страхует от гонки и от +# старой прошивки без этих команд — трактуется как «неизвестно» (None). +_SMOKE_TIMEOUT_S = 3.0 +_QSPI_TIMEOUT_S = 3.0 + + +class BootloaderClient(FirmwareClient): + """CDC-клиент загрузчика: ping/get_version (унаследованы) + smoke/qspi.""" + + async def connect(self) -> None: + """Открыть порт и проверить связь через ping→pong. + + Копия FirmwareClient.connect() с сообщением про загрузчик — в + QC-контексте «firmware_test не отвечает» вводило бы оператора в + заблуждение (проверяем-то загрузчик). Транспорт/ping идентичны. + """ + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._open) + if not await self.ping(): + await self.disconnect() + raise ConnectionError(f"Загрузчик не отвечает на ping: {self._port}") + + async def get_smoke_status( + self, timeout_s: float = _SMOKE_TIMEOUT_S + ) -> Optional[bool]: + """Запросить результат SDRAM/SEMC smoke-теста. + + :return: True (smoke_pass) / False (smoke_fail) / None — загрузчик не + ответил за timeout_s (результат ещё не закэширован либо старая + прошивка без команды). None ≠ провал: это «неизвестно». + """ + + def _match(ev: dict) -> Optional[bool]: + # Загрузчик отвечает обобщённым status-эвентом; фильтруем именно + # smoke_*, чтобы не спутать с waiting_for_sd/installing, которые + # main может слать в том же потоке. + if ev.get("type") == "status": + state = ev.get("state") + if state == "smoke_pass": + return True + if state == "smoke_fail": + return False + return None + + return await self._query_event("smoke_status", _match, timeout_s) + + async def get_qspi_info( + self, timeout_s: float = _QSPI_TIMEOUT_S + ) -> Optional[dict]: + """Запросить идентификацию QSPI-чипа. + + :return: dict с полями chip/mfr/cap_byte/size_mb/pass (as-is с прошивки), + либо None по таймауту (см. get_smoke_status про None). + """ + + def _match(ev: dict) -> Optional[dict]: + return ev if ev.get("type") == "qspi_info" else None + + return await self._query_event("qspi_info", _match, timeout_s) + + async def _query_event( + self, + cmd: str, + match: Callable[[dict], Optional[object]], + timeout_s: float, + ) -> Optional[object]: + """Отправить {"type":"cmd","cmd":cmd} и вернуть match() первого + подходящего события, либо None по таймауту. + + match(event) → не-None значение при совпадении (читаем дальше, пока + None). Неподходящие события (pong, чужой status, эхо прошлой команды) + молча пропускаются — тот же паттерн read-loop, что в ping(), но с + предикатом вместо фиксированного типа. + """ + await self._send({"type": "cmd", "cmd": cmd}) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + while loop.time() < deadline: + event = await loop.run_in_executor(None, self._read_line) + if event is not None: + result = match(event) + if result is not None: + return result + await asyncio.sleep(0) + return None diff --git a/tools/service_tui/app/flash_backend.py b/tools/service_tui/app/flash_backend.py index 2470dab..ec39634 100644 --- a/tools/service_tui/app/flash_backend.py +++ b/tools/service_tui/app/flash_backend.py @@ -349,6 +349,16 @@ class HabBuildError(FlashBackendError): """Ошибка сборки HAB-образа через HabImage (см. build_custom_hab).""" +class FlashVerifyError(FlashBackendError): + """Тир-0: readback записанного диапазона не совпал с исходным образом. + + Логическая ошибка (плата на месте, но байты во Flash не те, что писали — + редкий silent-corruption, не пойманный кодом статуса самой write-команды). + connection_lost наследуется False → UI остаётся на экране и показывает + сообщение, не уходит на WaitingScreen (вариант 2, Гейт 4a). + """ + + # SPSDKTimeoutError НЕ наследует SPSDKConnectionError (оба — потомки SPSDKError, # проверено по исходникам spsdk 3.7.0), поэтому один `except SPSDKConnectionError` # его пропускал → safety net в Flasher показывал «Непредвиденная ошибка» вместо @@ -480,6 +490,37 @@ def _is_blank(mboot: McuBoot, address: int, length: int) -> bool: return all(b == 0xFF for b in data) +def _verify_written(mboot: McuBoot, address: int, expected: bytes) -> None: + """Тир-0 (Фаза 5): прочитать записанный диапазон и сверить с оригиналом. + + Читаем ровно тот же диапазон, что записали (`address`, `len(expected)`), + и сравниваем sha256 — не побайтово, чтобы не тащить весь буфер в текст + ошибки и не зависеть от того, чанками ли spsdk вернул чтение. Любой сбой + (короткое/пустое чтение, несовпадение хэша) — FlashVerifyError: это + логическая ошибка «данные во Flash не те», плата на месте, обрыв тут ни + при чём (обрыв ловится _CONNECTION_LOST_EXCEPTIONS в вызывающем flash()). + + read_memory здесь НЕ оборачивается в _run_flash_cmd: это чтение, а не + команда с состоянием «выполнена/не выполнена» — короткое/пустое чтение + трактуется прямо как провал верификации (безопасный дефолт). + + :raises FlashVerifyError: readback короче ожидаемого или хэш не совпал. + """ + length = len(expected) + actual = mboot.read_memory(address, length, mem_id=0) + if not actual or len(actual) != length: + got = 0 if not actual else len(actual) + raise FlashVerifyError( + f"Верификация записи не удалась: прочитано {got} из {length} байт " + f"по 0x{address:08X} (status: {mboot.status_string})" + ) + if hashlib.sha256(actual).digest() != hashlib.sha256(expected).digest(): + raise FlashVerifyError( + f"Верификация записи не удалась: содержимое Flash по 0x{address:08X} " + f"не совпадает с образом ({length} байт) — возможна порча при записи" + ) + + def _emit( progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str ) -> None: @@ -638,6 +679,7 @@ def flash( *, ram_only: bool = False, fcb_path: Optional[Path] = None, + verify_readback: bool = True, progress_cb: Optional[ProgressCallback] = None, ) -> None: """Прошить HAB-образ в Flash либо загрузить в RAM (см. flash_usb.py::flash). @@ -647,7 +689,14 @@ def flash( :param ram_only: Загрузить в RAM через SDP, во Flash не писать. :param fcb_path: Явный FCB-блоб (custom-бинари). None → auto-config (write_fcb_auto, только для штатных firmware_test/production). + :param verify_readback: Тир-0 (Фаза 5) — после записи прочитать записанный + диапазон обратно и сверить sha256 с исходными байтами. + Ловит silent-corruption (write-команда вернула успех, но во + Flash попало не то), не пойманный кодом статуса. Дёшево по + действиям оператора (обычный read_memory, без смены BOOT_MOD). + Игнорируется при ram_only (во Flash ничего не пишем). :raises FlashBackendError: и подклассы — на любой ошибке. + :raises FlashVerifyError: readback не совпал с образом (плата на месте). :raises ConnectionLostError: обрыв USB посреди операции. """ if not hab_bin.exists(): @@ -739,6 +788,11 @@ def flash( ), ) + if verify_readback: + _emit(progress_cb, "verify", 0, "Верификация записи (readback)") + _verify_written(mboot, write_addr, data) + _emit(progress_cb, "verify", 100, "Верификация записи: OK") + _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) except _CONNECTION_LOST_EXCEPTIONS as exc: diff --git a/tools/service_tui/app/flasher.py b/tools/service_tui/app/flasher.py index 9b46aab..de0f5d9 100644 --- a/tools/service_tui/app/flasher.py +++ b/tools/service_tui/app/flasher.py @@ -262,20 +262,33 @@ class Flasher: ) elif target == FlashTarget.PRODUCTION: - result = await self._run_flash_op( + # Release ЖЁСТКО, в обход _FIRMWARE_BUILD_TYPE/FIRMWARE_BUILD_TYPE + # (та переменная — переключатель Debug/Release ТОЛЬКО для + # диагностической прошивки firmware_test, дефолт "Debug", см. её + # комментарий выше). Serial-прошивка не должна тихо зависеть от + # окружения: build/Debug/bootloader_hab.bin не подписан + # (flags=0x00 — Debug HAB-конфиг намеренно не трогали в Фазе 5), + # а именно подписанный Release — весь смысл production-пути. + hab_bin = flash_backend.firmware_hab_path("bootloader", "Release") + + # Сценарий A (Фаза 5): production = ТОЛЬКО загрузчик. Платы уходят в + # кучу и потом массово прошиваются tft_app с SD-карт. + # + # Прежний код здесь шил ещё и app_hab.bin, но ПО ТОМУ ЖЕ адресу + # FLASH_BASE (0x60000000) — второй шаг затирал только что записанный + # загрузчик. Это наследие монолитной эпохи (app как единственный + # XIP-образ) и для Direct-XIP неверно: tft_app живёт в СЛОТЕ + # (0x60040000, Slot A), проверяется bootutil'ом и подписывается + # imgtool'ом (НЕ HAB). Бандл bootloader+tft_app (сценарий B) — + # отдельная будущая работа вместе с реальным tft_app: писать + # bootloader_hab.bin → 0x60000000 и слинкованный-под-A подписанный + # образ → 0x60040000. См. docs/mimxrt1052/UPDATE_FLOW.md §5, §7. + return await self._run_flash_op( flash_backend.flash, - _firmware_hab_path("bootloader"), + hab_bin, async_progress_cb=progress_cb, sync_progress_cb=sync_cb, ) - if result.ok: - result = await self._run_flash_op( - flash_backend.flash, - _firmware_hab_path("app"), - async_progress_cb=progress_cb, - sync_progress_cb=sync_cb, - ) - return result elif target == FlashTarget.CUSTOM: if bin_path is None: diff --git a/tools/service_tui/app/models.py b/tools/service_tui/app/models.py index 199f2a6..9eb8993 100644 --- a/tools/service_tui/app/models.py +++ b/tools/service_tui/app/models.py @@ -33,7 +33,7 @@ class FlashTarget(Enum): """Что прошиваем.""" FIRMWARE_TEST = "firmware_test" - PRODUCTION = "production" # bootloader + tft_app + PRODUCTION = "production" # сценарий A: только загрузчик (бандл B — будущее, см. flasher.py) CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно @@ -72,12 +72,18 @@ class FlashPreset: выбор был неверным). DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM. + verify имеет смысл только при target == FlashTarget.PRODUCTION. """ target: FlashTarget = FlashTarget.FIRMWARE_TEST custom_bin_name: Optional[str] = None use_dcd: bool = False fcb_variant: FcbVariant = FcbVariant.W25Q128 + # Тир-1 (Фаза 5): после серийной прошивки прогнать живую проверку загрузчика + # (smoke-test SDRAM + идентификация QSPI по CDC). Требует смены BOOT_MOD + + # reset — раздражает при массовой заливке, поэтому OFF по умолчанию. Тир-0 + # (readback записи) выполняется всегда, независимо от этого флага. + verify: bool = False @dataclass(frozen=True) diff --git a/tools/service_tui/app/screens/__init__.py b/tools/service_tui/app/screens/__init__.py index 50c15c8..1e0da73 100644 --- a/tools/service_tui/app/screens/__init__.py +++ b/tools/service_tui/app/screens/__init__.py @@ -3,6 +3,13 @@ from .diag import DiagScreen from .flash import FlashScreen from .post_flash import PostFlashScreen +from .verify import VerifyScreen from .waiting import WaitingScreen -__all__ = ["WaitingScreen", "FlashScreen", "PostFlashScreen", "DiagScreen"] +__all__ = [ + "WaitingScreen", + "FlashScreen", + "PostFlashScreen", + "VerifyScreen", + "DiagScreen", +] diff --git a/tools/service_tui/app/screens/flash.py b/tools/service_tui/app/screens/flash.py index c9a0df0..f12606e 100644 --- a/tools/service_tui/app/screens/flash.py +++ b/tools/service_tui/app/screens/flash.py @@ -120,7 +120,7 @@ class FlashScreen(Screen, ConnectionWatcherMixin): value=self._preset.target == FlashTarget.FIRMWARE_TEST, ) yield RadioButton( - "Серийная прошивка (bootloader + tft_app)", + "Серийная прошивка (загрузчик)", id="radio-production", value=self._preset.target == FlashTarget.PRODUCTION, ) @@ -129,6 +129,20 @@ class FlashScreen(Screen, ConnectionWatcherMixin): id="radio-custom", value=self._preset.target == FlashTarget.CUSTOM, ) + is_production = self._preset.target == FlashTarget.PRODUCTION + with Vertical( + id="flash-production-group", + classes="" if is_production else "hidden", + ): + with Horizontal(id="flash-verify-row"): + yield Switch( + value=self._preset.verify, id="flash-verify-switch" + ) + yield Label( + "Верификация (smoke-test, требует смены BOOT_MOD)", + classes="section-title", + ) + is_custom = self._preset.target == FlashTarget.CUSTOM with Vertical( id="flash-custom-group", @@ -197,9 +211,14 @@ 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" - group = self.query_one("#flash-custom-group") - if is_custom: + self._toggle_group("#flash-custom-group", event.pressed.id == "radio-custom") + self._toggle_group( + "#flash-production-group", event.pressed.id == "radio-production" + ) + + def _toggle_group(self, selector: str, visible: bool) -> None: + group = self.query_one(selector) + if visible: group.remove_class("hidden") else: group.add_class("hidden") @@ -220,6 +239,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin): use_dcd=self._current_use_dcd(), fcb_variant=self._current_fcb_variant(), ) + elif target == FlashTarget.PRODUCTION: + preset = FlashPreset(target=target, verify=self._current_verify()) else: preset = FlashPreset(target=target) @@ -333,6 +354,9 @@ class FlashScreen(Screen, ConnectionWatcherMixin): def _current_use_dcd(self) -> bool: return self.query_one("#flash-dcd-switch", Switch).value + def _current_verify(self) -> bool: + return self.query_one("#flash-verify-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/service_tui/app/screens/verify.py b/tools/service_tui/app/screens/verify.py new file mode 100644 index 0000000..8a66917 --- /dev/null +++ b/tools/service_tui/app/screens/verify.py @@ -0,0 +1,241 @@ +""" +verify.py — экран Тир-1: живая проверка загрузчика после серийной прошивки. + +Показывается только для FlashTarget.PRODUCTION при включённом чек-боксе +«Верификация». После SDP-прошивки плата остаётся в SDP-режиме; чтобы запустить +свежезаписанный загрузчик (и получить smoke/qspi по CDC), оператор обязан +перевести BOOT_MOD_1 → GND + reset — тот же ручной шаг, что в PostFlashScreen, +но здесь за ним следует активная проверка, а не просто возврат в ожидание. + +Поток: + 1. Промпт смены BOOT_MOD; поллинг CDC (у загрузчика тот же VID:PID, что у + firmware_test — намеренно). + 2. CDC поднялся → BootloaderClient: ping (внутри connect) + get_version + + get_smoke_status + get_qspi_info. + 3. Отчёт pass/fail по полям. «Готово»/«Пропустить» → App вернёт на + WaitingScreen. + +Тир-0 (readback записи) к этому экрану отношения не имеет — он выполняется +всегда внутри самой прошивки (flash_backend), до и независимо от этого экрана. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Optional + +from textual import work +from textual.app import ComposeResult +from textual.containers import Center, Horizontal, Vertical +from textual.css.query import NoMatches +from textual.message import Message +from textual.screen import Screen +from textual.timer import Timer +from textual.widgets import Button, Label, Static + +from ..bootloader_client import BootloaderClient +from ..flasher import Flasher +from ..widgets import AppFrame + +logger = logging.getLogger(__name__) + +_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16) +_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16) + +# Время на смену BOOT_MOD + reset + boot загрузчика + USB-энумерацию. Щедро — +# оператор физически переключает пин; лучше не упереться в таймаут на медленной +# руке, чем сэкономить секунды. +_BOOT_WAIT_TIMEOUT_S = 45.0 +_POLL_INTERVAL_S = 1.0 +_SPIN_INTERVAL_S = 0.1 +_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + +def _mark(ok: bool) -> str: + return "✅" if ok else "❌" + + +class VerifyScreen(Screen): + """Тир-1: живая проверка загрузчика по CDC после серийной прошивки. + + Messages: + Done() — вернуться на WaitingScreen (готово/пропустить/таймаут). + """ + + class Done(Message): + """Проверка завершена (или пропущена) — пора в WaitingScreen.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._spinner_idx: int = 0 + self._spin_timer: Timer | None = None + self._status_text: str = ( + "Переведите плату в нормальный режим: BOOT_MOD_1 → GND → Reset" + ) + self._finished: bool = False + + def compose(self) -> ComposeResult: + with AppFrame(id="verify-frame"): + with Center(id="verify-title-row"): + yield Label("🔎 Верификация загрузчика", id="verify-title") + + with Center(id="verify-instruction-row"): + yield Static( + "После серийной прошивки плата осталась в режиме BootROM.\n" + "Чтобы проверить загрузчик, переведите её в нормальный режим:\n" + "BOOT_MOD_1 → GND → Reset", + id="verify-instruction", + ) + + with Center(id="verify-status-row"): + yield Static("", id="verify-status") + + yield Vertical(id="verify-results", classes="hidden") + + with Horizontal(id="verify-btn-row"): + yield Button( + "⏭ Пропустить проверку", id="verify-btn-done", variant="default" + ) + yield Button( + "✕ Выйти из приложения", id="verify-btn-quit", variant="default" + ) + + def on_mount(self) -> None: + self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin) + self._run_verify() + + def on_unmount(self) -> None: + if self._spin_timer is not None: + self._spin_timer.stop() + + # ── Обработчики ─────────────────────────────────────────────────────────── + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "verify-btn-done": + self.post_message(self.Done()) + elif event.button.id == "verify-btn-quit": + self.app.exit() + + # ── Внутреннее ──────────────────────────────────────────────────────────── + + def _spin(self) -> None: + if self._finished: + return + self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES) + try: + self.query_one("#verify-status", Static).update( + f"{_SPINNER_FRAMES[self._spinner_idx]} {self._status_text}" + ) + except NoMatches: + pass + + @work(exclusive=True, thread=False) + async def _run_verify(self) -> None: + client = await self._wait_and_connect() + if client is None: + self._render_no_board() + return + + version, smoke, qspi = "", None, None + try: + version = await client.get_version() + smoke = await client.get_smoke_status() + qspi = await client.get_qspi_info() + except Exception as exc: # noqa: BLE001 — любой сбой запроса → отчёт с «нет ответа» + logger.warning("Запрос к загрузчику не удался: %s", exc) + finally: + await client.disconnect() + + self._render_result(version, smoke, qspi) + + async def _wait_and_connect(self) -> Optional[BootloaderClient]: + """Ждать появления CDC загрузчика и подключиться (в пределах таймаута). + + detect_cdc() синхронный и быстрый (скан портов) — зовём напрямую, как + и остальной детект в проекте. auto_connect может не удаться, если порт + ещё не готов сразу после энумерации — тогда повторяем на следующей + итерации поллинга, пока не истечёт дедлайн. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + _BOOT_WAIT_TIMEOUT_S + while loop.time() < deadline: + if Flasher.detect_cdc(): + self._status_text = "Плата найдена, проверяю загрузчик..." + try: + return await BootloaderClient.auto_connect( + vid=_CDC_VID, pid=_CDC_PID + ) + except Exception as exc: # noqa: BLE001 — порт не готов → ретрай + logger.info("Подключение к загрузчику, повтор: %s", exc) + await asyncio.sleep(_POLL_INTERVAL_S) + return None + + def _render_no_board(self) -> None: + """Загрузчик не поднялся за отведённое время.""" + self._finish() + try: + self.query_one("#verify-status", Static).update( + f"⚠ Загрузчик не ответил за {int(_BOOT_WAIT_TIMEOUT_S)}с. " + "Проверьте BOOT_MOD и подключение USB." + ) + # Дальше нечего пропускать — было ожидание, не активная проверка. + self.query_one("#verify-btn-done", Button).label = "✓ Готово" + except NoMatches: + pass + + def _render_result( + self, version: str, smoke: Optional[bool], qspi: Optional[dict] + ) -> None: + self._finish() + + ok_version = bool(version) + ok_smoke = smoke is True + ok_qspi = qspi is not None and bool(qspi.get("pass")) + overall = ok_version and ok_smoke and ok_qspi + + lines: list[str] = [] + lines.append( + f"{_mark(ok_version)} Загрузчик отвечает" + + (f": v{version}" if version else ": нет ответа") + ) + if smoke is True: + lines.append("✅ SDRAM smoke-test: пройден") + elif smoke is False: + lines.append("❌ SDRAM smoke-test: провал") + else: + lines.append("⚠ SDRAM smoke-test: нет ответа") + if qspi is None: + lines.append("⚠ QSPI-чип: нет ответа") + else: + chip = qspi.get("chip", "?") + size_mb = qspi.get("size_mb", "?") + lines.append(f"{_mark(ok_qspi)} QSPI-чип: {chip} ({size_mb} МБ)") + + try: + status = self.query_one("#verify-status", Static) + if overall: + status.update("✅ Верификация пройдена") + status.remove_class("verify-fail") + status.add_class("verify-pass") + else: + status.update("❌ Верификация НЕ пройдена") + status.remove_class("verify-pass") + status.add_class("verify-fail") + + results = self.query_one("#verify-results", Vertical) + results.remove_children() + for line in lines: + results.mount(Static(line, classes="verify-result-line")) + results.remove_class("hidden") + + self.query_one("#verify-btn-done", Button).label = "✓ Готово" + except NoMatches: + pass + + def _finish(self) -> None: + """Остановить спиннер и зафиксировать экран в терминальном состоянии.""" + self._finished = True + if self._spin_timer is not None: + self._spin_timer.stop() diff --git a/tools/service_tui/docs/DEV_ARCH.md b/tools/service_tui/docs/ARCHITECTURE.md similarity index 80% rename from tools/service_tui/docs/DEV_ARCH.md rename to tools/service_tui/docs/ARCHITECTURE.md index 3933457..ae2205e 100644 --- a/tools/service_tui/docs/DEV_ARCH.md +++ b/tools/service_tui/docs/ARCHITECTURE.md @@ -21,7 +21,8 @@ tools/service_tui/ ├── custom_binaries/ ← runtime, gitignored, создаётся автоматически │ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое» ├── tests/ -│ └── test_flash_backend.py ← unit-тесты flash_backend.py (45 тестов, без event loop) +│ ├── test_flash_backend.py ← unit-тесты flash_backend.py, включая Тир-0 readback (Фаза 5) +│ └── test_bootloader_client.py ← unit-тесты BootloaderClient (фейковый serial, Фаза 5) ├── spike/ ← Фаза 0, де-риск spsdk API (в релиз не идёт) └── app/ ├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов @@ -29,20 +30,27 @@ tools/service_tui/ ├── models.py ← все типы данных (dataclass/Enum) ├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen ├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8) + ├── bootloader_client.py ← async CDC клиент bootloader (Фаза 5) — тонкий подкласс + │ FirmwareClient, добавляет get_smoke_status()/get_qspi_info() ├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8) ├── usb_ports.py ← resolve_serial_port() — резолв COM/tty по VID:PID (Р8) ├── flash_backend.py ← синхронное ядро прошивки: прямой spsdk API (McuBoot/SDP/HabImage), - │ zero Textual/asyncio импортов, тестируется без event loop - ├── flasher.py ← async-обёртка над flash_backend.py (asyncio.to_thread) + │ zero Textual/asyncio импортов, тестируется без event loop. + │ Фаза 5: Тир-0 readback-верификация записи, всегда включена + ├── flasher.py ← async-обёртка над flash_backend.py (asyncio.to_thread). + │ Фаза 5: PRODUCTION = только bootloader, Release жёстко ├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты ├── widgets/ │ ├── __init__.py │ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов └── screens/ - ├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen + ├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, + │ VerifyScreen, DiagScreen ├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата ├── flash.py ← FlashScreen — прошивка / chip erase - ├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки + ├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки firmware_test + ├── verify.py ← VerifyScreen (Фаза 5) — Тир-1: живой smoke-test bootloader + │ по CDC после серийной прошивки, по чек-боксу «Верификация» ├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB └── diag/ ├── __init__.py ← DiagScreen — координатор диагностики @@ -130,14 +138,22 @@ stateDiagram-v2 WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC FLASHING --> POST_FLASH : firmware_test прошит успешно - FLASHING --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое + FLASHING --> VERIFYING : Production прошит успешно\n+ чек-бокс «Верификация» ON (Фаза 5) + FLASHING --> WAITING : Production (verify OFF)/Custom прошит,\nошибка, или потеря USB в простое POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с + VERIFYING --> WAITING : «Готово» / «Пропустить» / таймаут 45с (Фаза 5) DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое DIAGNOSING --> FLASHING: плата переведена в SDP (перемычка BOOT_MOD) ``` +**VERIFYING (Фаза 5, bootloader `PLAN.md`)** — Тир-1: живая проверка +загрузчика по USB CDC (`BootloaderClient`, см. §16). Не идёт через обычный +`WaitingScreen`-автодетект (bootloader и firmware_test делят VID:PID, но у +bootloader нет `list_tests` — автодетект увёл бы в `DIAGNOSING` и там +завис бы) — отдельная ветка сразу из `FLASHING`, симметрично `POST_FLASH`. + Состояния соответствуют `AppMode` в `models.py`; переключение экранов — `ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на сообщения `DeviceDetected`/`FlashDone`/`DiagDone`. @@ -404,6 +420,9 @@ class FlashPreset: custom_bin_name: Optional[str] = None use_dcd: bool = False fcb_variant: FcbVariant = FcbVariant.W25Q128 + verify: bool = False # Фаза 5 — Тир-1 при PRODUCTION, см. §16. OFF по + # умолчанию: массовая заливка партии не должна + # требовать смены BOOT_MOD на каждой плате ``` `FlashPreset` — «липкий» выбор оператора, живёт в `ServiceApp._last_flash_preset` @@ -571,6 +590,7 @@ graph TB WS["WaitingScreen"] FS["FlashScreen"] PF["PostFlashScreen"] + VS["VerifyScreen (Фаза 5)"] DS["DiagScreen"] end @@ -583,6 +603,7 @@ graph TB subgraph Clients["Клиенты"] FC["FirmwareClient"] + BC["BootloaderClient (Фаза 5)\nподкласс FirmwareClient"] M5["M5Client"] FL["Flasher"] end @@ -590,11 +611,14 @@ graph TB WS -->|"DeviceDetected(FLASHING)"| FS WS -->|"DeviceDetected(DIAGNOSING)"| DS FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF + FS -->|"FlashDone(success=True, target=PRODUCTION,\npreset.verify=True)"| VS FS -->|"FlashDone(остальное)"| WS PF -->|"Done"| WS + VS -->|"Done"| WS DS -->|"DiagDone(reason)"| WS FS --> FL + VS --> BC DS --> OR DS --> TL DS --> RP @@ -608,6 +632,12 @@ graph TB DS -.->|"ConnectionWatcherMixin"| FL ``` +`VerifyScreen` намеренно **не** использует `ConnectionWatcherMixin` — та +логика предполагает уже установленное соединение, которое может разорваться, +а здесь наоборот: соединения ещё нет, экран сам поллит появление CDC (до +45с) и подключается, когда оператор физически переведёт плату в обычный +режим. См. §16. + --- ## 10. Жизненный цикл диагностической сессии @@ -800,10 +830,24 @@ service-tui-vX.Y.Z-/ │ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data │ └── ... ← рантайм PyInstaller, libusbsio ├── firmware/ -│ └── /firmware_test_hab.bin +│ ├── Debug/firmware_test_hab.bin ← firmware_test всегда Debug (см. ниже) +│ └── Release/bootloader_hab.bin ← bootloader всегда Release, подписанный +│ (Фаза 5, bootloader PLAN.md — production +│ жёстко требует именно этот файл) └── custom_binaries/ ← пустая, создаётся оператором/автоматически ``` +**Гвард на `bootloader_hab.bin` (Фаза 5).** `just host::package-tui` +копирует `*_hab.bin` из `build/Debug/` и `build/Release/` по маске — до +Фазы 5 отсутствие Release-образа bootloader проходило незамеченным (просто +не копировался файл, которого никто ещё не требовал). После того как +`Flasher.PRODUCTION` стал жёстко резолвить `firmware/Release/bootloader_hab.bin` +(без фоллбэка на Debug — см. §16), молчаливое отсутствие стало тихой +runtime-бомбой: бандл собирается «успешно», но «Серийная прошивка» в нём +не работает. Рецепт теперь падает явно (`❌ Не найден .../Release/ +bootloader_hab.bin`), если файла нет — симметрично уже существовавшей +проверке `found_debug` для firmware_test. + Каждый модуль, которому нужен путь к данным, сам решает dev vs frozen через `getattr(sys, "frozen", False)` — единообразный паттерн по всему `app/`: @@ -823,7 +867,7 @@ service-tui-vX.Y.Z-/ означает, что `libusb-1.0.*`/Zadig в бандле **не нужны** ни на Windows, ни на macOS — детект BootROM SDP и Flashloader работает из коробки. -`service_tui.spec` актуализирован и ужесточён (FIRST_RELEASE_PLAN.md, Шаг 1): +`service_tui.spec` актуализирован и ужесточён: `collect_data_files("spsdk")`, `collect_dynamic_libs("libusbsio")`, `datas` для `tools/host/dcd/*.bin` (→ `data/` внутри `_internal`) и `pyproject.toml`. Сборка падает с `FileNotFoundError` уже на этапе генерации спека, если в @@ -834,7 +878,7 @@ service-tui-vX.Y.Z-/ `.gitignore`) — собранные бандлы это build-артефакты, не история репозитория. Пересобрано и провалидировано на живом железе macOS + Windows после актуализации spec (Гейт 5: детект SDP → прошивка `firmware_test` → -диагностика → выход) — см. `FIRST_RELEASE_PLAN.md`, Шаг 1.4. +диагностика → выход). --- @@ -862,16 +906,145 @@ service-tui-vX.Y.Z-/ --- +## 16. Верификация после серийной прошивки (Тир-0/Тир-1, Фаза 5) + +Полный контекст решения (HAB-подпись bootloader тестовым ключом, CI, +обсуждённые trade-off'ы) — `firmware/bootloader/PLAN.md`, Фаза 5. Здесь — +только service-tui-специфичная реализация: `BootloaderClient`, +`VerifyScreen`, Тир-0 в `flash_backend.py`, и два бага, найденных в +`flasher.py` по пути. + +### 16.1 Почему два уровня, а не один чек-бокс + +После SDP-прошивки плата остаётся в режиме Flashloader — `mboot.reset()` +без физической смены `BOOT_MOD` вернёт её обратно в SDP, не запустит +свежезалитый образ. Любая проверка «плата реально ожила» стоит одного +ручного тоггла пина на плату, не секунд на CDC-обмен. Отсюда разделение: + +| Тир | Что проверяет | Стоимость оператору | Включение | +| --- | --- | --- | --- | +| Тир-0 | Байты во Flash совпадают с записанным образом (readback + sha256) | Ноль — автоматически, для ЛЮБОЙ прошивки | Всегда, `verify_readback=True` | +| Тир-1 | Загрузчик реально грузится и отвечает (SDRAM smoke + QSPI-чип) | Один тоггл `BOOT_MOD` на плату | Чек-бокс «Верификация» на `FlashScreen`, OFF по умолчанию — сценарий A (bootloader-only, платы уходят в кучу) не должен требовать тоггла на каждую | + +### 16.2 Тир-0 — `flash_backend._verify_written()` + +Внутри `flash()`, между `write_memory()` и `reset()`: читает записанный +диапазон обратно (`mboot.read_memory`) и сравнивает `sha256` с исходными +байтами образа — не побайтово (короче для текста ошибки, не зависит от +чанкинга spsdk). Несовпадение/короткое чтение → `FlashVerifyError` +(подкласс `FlashBackendError`, `connection_lost=False` — логическая +ошибка, плата на месте, экран не уходит на `WaitingScreen`). НЕ +оборачивается в `_run_flash_cmd()` (retry, Р14) — это чтение, не команда с +состоянием «выполнена/не выполнена»; любой сбой читается как провал +верификации напрямую. + +`verify_readback: bool = True` — единый дефолт для **всех** целей +(`firmware_test`/`PRODUCTION`/`CUSTOM`), не только production: readback +дёшев (обычный `read_memory`, без смены `BOOT_MOD`) и одинаково полезен +везде, гейтить его отдельным флагом сочли ненужным усложнением. + +### 16.3 Тир-1 — `BootloaderClient` + +Тонкий подкласс `FirmwareClient` (`app/bootloader_client.py`) — транспорт, +`ping()`, `get_version()` наследуются как есть (bootloader и firmware_test +делят один JSON-lines протокол и VID:PID намеренно). Добавляет: + +- `get_smoke_status() -> Optional[bool]` — фильтрует `status`-эвенты именно + на `smoke_pass`/`smoke_fail`, пропуская мимо `waiting_for_sd`/`installing` + (тот же тип события, другой смысл). +- `get_qspi_info() -> Optional[dict]` — `chip`/`mfr`/`cap_byte`/`size_mb`/`pass` + as-is с прошивки. +- Общий приватный `_query_event(cmd, match, timeout_s)` — тем же + read-loop-паттерном, что уже использует `ping()` в `FirmwareClient`. + +**`None` — не провал, а «неизвестно».** Прошивка (`protocol_send_smoke_status`/ +`protocol_send_qspi_info`) отвечает **только если результат уже закэширован** +на раннем `main()` — иначе молчит совсем. Таймаут (`_SMOKE_TIMEOUT_S`/ +`_QSPI_TIMEOUT_S = 3.0`с) трактуется как «нет ответа», не «провалено» — +`VerifyScreen` рисует такие пункты как `⚠`, не `❌` (см. §16.4). + +`connect()` переопределён только ради текста ошибки («Загрузчик не +отвечает…» вместо «firmware_test не отвечает…») — в QC-контексте важно не +путать оператора, что именно проверяется. + +### 16.4 `VerifyScreen` + +Показывается вместо `WaitingScreen` сразу после успешной `PRODUCTION`- +прошивки, если `preset.verify == True` (см. `app.py::_on_flash_done`). +Поток: промпт `BOOT_MOD_1 → GND → Reset` → поллинг `Flasher.detect_cdc()` +раз в секунду (до `_BOOT_WAIT_TIMEOUT_S = 45`с) → на детекте — +`BootloaderClient.auto_connect()` с ретраем на самой функции (порт может +быть виден в `comports()`, но ещё не готов к открытию сразу после +энумерации — тот же класс гонки, что уже описан в §10 для M5) → запрос +`get_version()`/`get_smoke_status()`/`get_qspi_info()` → рендер отчёта. + +Итоговый вердикт («✅ Верификация пройдена») требует **все три** пункта +успешными (версия получена, smoke `True`, qspi `pass: true`) — частичный +успех или `None` по любому пункту даёт общий ❌ с построчной детализацией, +что именно не ответило/провалилось. Кнопка «⏭ Пропустить проверку» +доступна всё время ожидания; после отчёта (успешного или по таймауту) +меняет подпись на «✓ Готово» — после результата уже нечего пропускать. + +**Сознательно не через `WaitingScreen`-автодетект.** Bootloader и +firmware_test делят VID:PID — если пустить проверку через общий +`_poll_usb()`, он определит режим как `DIAGNOSING` и уведёт в `DiagScreen`, +которая ждёт `list_tests`/`run_selected` (их у bootloader нет). Поэтому +`VerifyScreen` — отдельная, самодостаточная ветка сразу из `FlashScreen` +(см. диаграмму состояний §3). + +### 16.5 Два бага, найденные в `flasher.py` по пути + +1. **`PRODUCTION` затирал только что записанный bootloader.** Старый код + шил bootloader И app **по одному и тому же адресу** `FLASH_BASE` + (0x60000000) — наследие монолитной пре-bootloader эпохи. Для Direct-XIP + в принципе неверно: tft_app должен идти в Slot A/Б, другим механизмом + подписи (imgtool, не HAB). tft_app ещё не реализован — production сужен + до **сценария A** (только загрузчик); правильный бандл (сценарий B) — + будущая работа вместе с реальным tft_app, см. + `docs/mimxrt1052/UPDATE_FLOW.md` §5, §7. +2. **`PRODUCTION` мог тихо взять unsigned Debug-образ.** Путь резолвился + через `_FIRMWARE_BUILD_TYPE` — ту же переменную окружения, что + переключает Debug/Release **только для диагностической прошивки** + (дефолт `"Debug"`, см. `README.md` §«Конфигурация»). Без явного + `FIRMWARE_BUILD_TYPE=Release` в окружении серийная прошивка залила бы + `build/Debug/bootloader_hab.bin` — unsigned, Debug HAB-конфиг сознательно + не подписывается. Исправлено: `PRODUCTION` резолвит `Release` жёстко + (`flash_backend.firmware_hab_path("bootloader", "Release")`), в обход + переменной окружения. + +### 16.6 Аппаратная верификация + +Полный UI-цикл пройден на реальной плате: регрессия существующих потоков +(firmware_test/custom/erase не задеты) → Тир-0 незаметно проходит на +обычных прошивках (новая строка «Верификация записи: OK» в +`#flash-log`) → `PRODUCTION` OFF грузит только bootloader → `PRODUCTION` +ON → `VerifyScreen` → живой отчёт pass/pass/pass → `WaitingScreen`; отдельно +проверены кнопка «Пропустить» и ветка таймаута; «липкий» `FlashPreset` +переносит состояние чек-бокса на следующую плату. Подпись HAB-образа +подтверждена независимо от TUI — прямым чтением IVT с чипа через SWD +(`pyocd commander ... read32 0x60001000 32`, поле `csf` ненулевое). + +Host-тесты: `uv run pytest tests/` — **76/76** (было 68; +8 +`test_bootloader_client.py`, обновлены/добавлены тесты Тир-0 в +`test_flash_backend.py`). + +### 16.7 Известное ограничение (не исправлено, задокументировано) + +Загрузчик, оставленный подключённым **после** успешной верификации (минуя +`WaitingScreen`, оператор не отключил плату), будет неправильно +маршрутизирован при следующем автодетекте — см. §16.4 выше про причину +(общий VID:PID, нет `list_tests`). На практике не мешает: в сценарии A +плата снимается со стенда сразу после прошивки/верификации, не остаётся +подключённой к TUI. Актуальным станет при появлении сценария B. + +--- + ## Известные открытые вопросы -- **Документация — Фаза 6 (текущая).** Инженерные фазы 0–5 (backend на spsdk, - обработка обрыва USB, троттлинг логов, упаковка PyInstaller) закрыты в - коде; `docs/DEV_ARCH.md`/`README.md` актуализированы этой правкой. Осталось - по `RELEASE_ROADMAP.md` §Фаза 6: `CHANGELOG.md` (не заведён), grep-зачистка - устаревших docstring-упоминаний `flash_usb.py`/`subprocess` в - `app/flash.py` (комментарий `_check_sdp_present`) и `flasher.py` - (docstring модуля упоминает Фазу 2 буквально, что нормально как история - провенанса, но стоит перепроверить при следующей правке этих файлов). +- ~~Grep-зачистка устаревших docstring-упоминаний `flash_usb.py`/ + `subprocess`~~ — перепроверено при правках Фазы 5 (bootloader + `PLAN.md`): `app/flash.py` уже чист, упоминание в `flasher.py` — легитимная + история провенанса (Фаза 2), не ошибка. Закрыто. - **Release-сборка firmware нестабильна** (медленное мигание — подозрение на проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно форсирует Debug через `FIRMWARE_BUILD_TYPE`. @@ -881,8 +1054,7 @@ service-tui-vX.Y.Z-/ - Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и «копирование UID с экрана» — отложены, не начаты. - **POST-1 (циклический прогон неинтерактивных тестов на DiagScreen)** — - сознательно отложен на пост-релиз, вне `MONOLITH_APP_PLAN.md` (см. - `RELEASE_ROADMAP.md`). + сознательно отложен на пост-релиз, не начат. - **Массовое программирование** — решено НЕ делать авто-прошивку по факту детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем понадобится полный батч-режим — потребуется отдельный предохранитель diff --git a/tools/service_tui/pyproject.toml b/tools/service_tui/pyproject.toml index 7dd8427..d435e9a 100644 --- a/tools/service_tui/pyproject.toml +++ b/tools/service_tui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "service-tui" -version = "0.2.1" +version = "0.3.0" description = "TUI сервисного инженера для диагностики платы MIMXRT1052" requires-python = ">=3.11" dependencies = [ diff --git a/tools/service_tui/tests/test_bootloader_client.py b/tools/service_tui/tests/test_bootloader_client.py new file mode 100644 index 0000000..fb5cf2a --- /dev/null +++ b/tools/service_tui/tests/test_bootloader_client.py @@ -0,0 +1,116 @@ +""" +test_bootloader_client.py — юнит-тесты BootloaderClient (Фаза 5). + +Тестируем чистую логику (парсинг/фильтрация/таймаут) без железа: подменяем +._ser фейковым serial'ом с очередью строк. asyncio.run() вместо pytest-asyncio +(плагин не в зависимостях, а корутины тут короткие) — тот же принцип, что и в +sync-стиле test_flash_backend.py. +""" + +from __future__ import annotations + +import asyncio + +from app.bootloader_client import BootloaderClient + + +class _FakeSerial: + """Минимальный stand-in для serial.Serial: отдаёт заранее заданные строки + (bytes) по одной на readline(), затем b"" (как реальный таймаут readline). + """ + + def __init__(self, lines: list[bytes]) -> None: + self._lines = list(lines) + self.written: list[bytes] = [] + self.is_open = True + + def write(self, data: bytes) -> None: + self.written.append(data) + + def flush(self) -> None: + pass + + def readline(self) -> bytes: + return self._lines.pop(0) if self._lines else b"" + + def reset_input_buffer(self) -> None: + pass + + def close(self) -> None: + self.is_open = False + + +def _client(lines: list[bytes]) -> BootloaderClient: + client = BootloaderClient(port="fake") + client._ser = _FakeSerial(lines) + return client + + +# ─── get_smoke_status ─────────────────────────────────────────────────────── + + +def test_smoke_status_pass(): + client = _client([b'{"type":"status","state":"smoke_pass"}\n']) + assert asyncio.run(client.get_smoke_status(timeout_s=1.0)) is True + + +def test_smoke_status_fail(): + client = _client([b'{"type":"status","state":"smoke_fail"}\n']) + assert asyncio.run(client.get_smoke_status(timeout_s=1.0)) is False + + +def test_smoke_status_ignores_unrelated_status_then_matches(): + """waiting_for_sd/installing — тоже status-эвенты; их надо пропустить и + дочитать до реального smoke_*.""" + client = _client( + [ + b'{"type":"status","state":"waiting_for_sd"}\n', + b'{"type":"pong"}\n', + b'{"type":"status","state":"smoke_fail"}\n', + ] + ) + assert asyncio.run(client.get_smoke_status(timeout_s=1.0)) is False + + +def test_smoke_status_timeout_returns_none(): + """Прошивка молчит, если результат ещё не закэширован → None (не провал).""" + client = _client([]) + assert asyncio.run(client.get_smoke_status(timeout_s=0.05)) is None + + +def test_smoke_status_sends_correct_command(): + client = _client([b'{"type":"status","state":"smoke_pass"}\n']) + asyncio.run(client.get_smoke_status(timeout_s=1.0)) + assert client._ser.written == [b'{"type":"cmd","cmd":"smoke_status"}\n'] + + +# ─── get_qspi_info ────────────────────────────────────────────────────────── + + +def test_qspi_info_parsed(): + line = ( + b'{"type":"qspi_info","chip":"W25Q128","mfr":"0xEF",' + b'"cap_byte":"0x18","size_mb":16,"pass":true}\n' + ) + result = asyncio.run(_client([line]).get_qspi_info(timeout_s=1.0)) + assert result is not None + assert result["chip"] == "W25Q128" + assert result["size_mb"] == 16 + assert result["pass"] is True + + +def test_qspi_info_ignores_other_events_then_matches(): + client = _client( + [ + b'{"type":"pong"}\n', + b'{"type":"qspi_info","chip":"W25Q64","mfr":"0xEF",' + b'"cap_byte":"0x17","size_mb":8,"pass":false}\n', + ] + ) + result = asyncio.run(client.get_qspi_info(timeout_s=1.0)) + assert result["chip"] == "W25Q64" + assert result["pass"] is False + + +def test_qspi_info_timeout_returns_none(): + assert asyncio.run(_client([]).get_qspi_info(timeout_s=0.05)) is None diff --git a/tools/service_tui/tests/test_flash_backend.py b/tools/service_tui/tests/test_flash_backend.py index bece551..6744380 100644 --- a/tools/service_tui/tests/test_flash_backend.py +++ b/tools/service_tui/tests/test_flash_backend.py @@ -242,7 +242,13 @@ def _mock_mcuboot_ctx(monkeypatch, **method_returns): у fill_memory/configure_memory/flash_erase_region/write_memory (все True по умолчанию, кроме явно переопределённых). read_memory по умолчанию возвращает «пустой чип» (Р16) — существующие happy-path тесты ожидают - flash_erase_region, не flash_erase_all.""" + flash_erase_region, не flash_erase_all. + + Фаза 5 (Тир-0): read_memory теперь stateful — write_memory кладёт байты в + `ctx.flash_model` (dict addr→bytes), а read_memory по записанному адресу + отдаёт ровно записанное (эхо), иначе — `read_memory`-дефолт (blank-check + Р16 читает FLASH_BASE ДО любой записи → модель пуста → дефолт). Так + verify-readback happy-path совпадает без явной настройки в каждом тесте.""" defaults = dict( configure_memory=True, flash_erase_region=True, @@ -254,8 +260,25 @@ def _mock_mcuboot_ctx(monkeypatch, **method_returns): ctx = MagicMock() ctx.__enter__.return_value = ctx - for name, ret in defaults.items(): - getattr(ctx, name).return_value = ret + + flash_model: dict = {} + ctx.flash_model = flash_model # доступ для тестов, переопределяющих write + + def _write_memory(address, data, mem_id=0, progress_callback=None): + if defaults["write_memory"]: + flash_model[address] = bytes(data) + return defaults["write_memory"] + + def _read_memory(address, length, mem_id=0): + if address in flash_model: # Тир-0 readback: эхо записанного + return flash_model[address][:length] + return defaults["read_memory"] # Р16 blank-check по умолчанию + + ctx.write_memory.side_effect = _write_memory + ctx.read_memory.side_effect = _read_memory + ctx.configure_memory.return_value = defaults["configure_memory"] + ctx.flash_erase_region.return_value = defaults["flash_erase_region"] + ctx.flash_erase_all.return_value = defaults["flash_erase_all"] monkeypatch.setattr(fb, "McuBoot", Mock(return_value=ctx)) return ctx @@ -357,12 +380,15 @@ def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path): ctx.reset.assert_called_once_with(reopen=False) # Р16: два "erase"-события — проверка блочности + собственно стирание. + # Фаза 5: два "verify"-события (старт + OK) между write и reset. assert _phases(events) == [ "configure", "erase", "erase", "fcb", "write", + "verify", + "verify", "reset", "done", ] @@ -405,6 +431,48 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path): assert "done" not in _phases(events) +def test_flash_verify_readback_mismatch(monkeypatch, events, tmp_path): + """Тир-0 (Фаза 5): write-команда вернула успех, но readback не совпал с + образом → FlashVerifyError (логическая ошибка, не обрыв), reset не + происходит.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 63) + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + ctx = _mock_mcuboot_ctx(monkeypatch) + # read_memory всегда отдаёт нули нужной длины: blank-check видит непустой + # чип (полный erase — не важно для теста), verify видит данные ≠ образу. + ctx.read_memory.side_effect = lambda addr, length, mem_id=0: b"\x00" * length + + with pytest.raises(fb.FlashVerifyError, match="не совпадает"): + fb.flash(hab_bin, progress_cb=_collector(events)) + + assert ctx.reset.call_count == 0 + assert "done" not in _phases(events) + # Логическая ошибка — не обрыв связи. + with pytest.raises(fb.FlashVerifyError) as ei: + fb.flash(hab_bin) + assert ei.value.connection_lost is False + + +def test_flash_verify_disabled_skips_readback(monkeypatch, events, tmp_path): + """verify_readback=False → фаза 'verify' не эмитится, read_memory по + write-адресу не вызывается; reset/done в порядке.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 63) + write_addr = fb.FLASH_BASE + fb.HAB_OFFSET + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + ctx = _mock_mcuboot_ctx(monkeypatch) + + fb.flash(hab_bin, verify_readback=False, progress_cb=_collector(events)) + + assert "verify" not in _phases(events) + assert "done" in _phases(events) + read_addrs = [call.args[0] for call in ctx.read_memory.call_args_list] + assert write_addr not in read_addrs # только blank-check по FLASH_BASE + + def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path): """write_memory реально дёргает progress_callback(current, total) — см. Фазу 0. Проверяем, что наш адаптер конвертирует это в FlashProgress.""" @@ -416,6 +484,7 @@ def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path): ctx = _mock_mcuboot_ctx(monkeypatch) def _fake_write_memory(address, data, mem_id=0, progress_callback=None): + ctx.flash_model[address] = bytes(data) # чтобы Тир-0 readback совпал progress_callback(len(data) // 2, len(data)) progress_callback(len(data), len(data)) return True diff --git a/tools/service_tui/uv.lock b/tools/service_tui/uv.lock index bdf9c7e..ad7ad21 100644 --- a/tools/service_tui/uv.lock +++ b/tools/service_tui/uv.lock @@ -1126,7 +1126,7 @@ wheels = [ [[package]] name = "service-tui" -version = "0.2.1" +version = "0.3.0" source = { virtual = "." } dependencies = [ { name = "pyinstaller" },