# bootloader, service-tui: ready for release

This commit is contained in:
Dmitry Akimov 2026-07-20 15:52:22 +03:00
parent 6c564f1638
commit 6120e71416
37 changed files with 1884 additions and 1265 deletions

View file

@ -1,17 +1,22 @@
name: Release name: Release
# Раздельные теги (FIRST_RELEASE_PLAN.md, Шаг 2.1): firmware_test и # Раздельные теги (bootloader — Фаза 5, firmware/bootloader/PLAN.md):
# service-tui версионируются и релизятся независимо друг от друга. # firmware_test, bootloader и 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) # firmware-vX.Y.Z → publish-firmware (только firmware_test_hab.bin, Debug)
# HAB firmware_test, вшиваемый в TUI-бандл, всегда собирается заново из # bootloader-vX.Y.Z → publish-bootloader (только bootloader_hab.bin, Release,
# текущего HEAD тега tui-v*, а не берётся из отдельного firmware-релиза — # подписан ТЕСТОВЫМ HAB-ключом — см.
# так проще и не тянет зависимость на чужой GitHub Release. # 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: on:
push: push:
tags: tags:
- "tui-v*" - "tui-v*"
- "firmware-v*" - "firmware-v*"
- "bootloader-v*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release_type: release_type:
@ -20,6 +25,7 @@ on:
options: options:
- tui - tui
- firmware - firmware
- bootloader
default: tui default: tui
concurrency: concurrency:
@ -30,11 +36,12 @@ permissions:
jobs: jobs:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# firmware — собирает HAB Debug firmware_test. Нужна как для standalone # firmware — собирает HAB Debug firmware_test И HAB Release bootloader.
# firmware-релиза, так и для вшивания в TUI-бандл — выполняется всегда. # Нужна как для standalone firmware-/bootloader-релизов, так и для вшивания
# обоих образов в TUI-бандл (package-tui, Фаза 5) — выполняется всегда.
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
firmware: firmware:
name: Build firmware_test HAB (Debug) name: Build firmware_test HAB (Debug) + bootloader HAB (Release)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90 timeout-minutes: 90
@ -86,6 +93,30 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 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-релиз # publish-firmware — только на тег firmware-v*, отдельный standalone-релиз
# HAB-образа (для tools/host/flash_usb.py и ручной прошивки, не через TUI). # HAB-образа (для tools/host/flash_usb.py и ручной прошивки, не через TUI).
@ -116,6 +147,40 @@ jobs:
--title "firmware_test ${GITHUB_REF_NAME#firmware-v}" \ --title "firmware_test ${GITHUB_REF_NAME#firmware-v}" \
--generate-notes --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 # service-tui-{macos,windows} — упаковка PyInstaller-бандла с вшитым HAB
# из job firmware. Запускается на тег tui-v* и на workflow_dispatch с # из job firmware. Запускается на тег tui-v* и на workflow_dispatch с
@ -144,6 +209,12 @@ jobs:
name: firmware-hab-debug name: firmware-hab-debug
path: build/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 - name: Проверить, что тег совпадает с версией в pyproject.toml
if: startsWith(github.ref, 'refs/tags/tui-v') if: startsWith(github.ref, 'refs/tags/tui-v')
run: | run: |
@ -197,6 +268,12 @@ jobs:
name: firmware-hab-debug name: firmware-hab-debug
path: build/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 - name: Проверить, что тег совпадает с версией в pyproject.toml
if: startsWith(github.ref, 'refs/tags/tui-v') if: startsWith(github.ref, 'refs/tags/tui-v')
shell: bash shell: bash

View file

@ -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. - Отслеживать появление self-hosted runner, регулярных аппаратных прогонов или отчётов по HIL.
- Проверять новые firmware-test модули в `firmware/test/src/tests/` и синхронные обновления протокола/документации. - Проверять новые firmware-test модули в `firmware/test/src/tests/` и синхронные обновления протокола/документации.
- Следить за развитием BSP: RGB (частично закрыто display-тестом), bootloader или `tft_app`обе директории всё ещё не заведены. - Следить за развитием BSP: RGB (частично закрыто display-тестом); `tft_app` — директория всё ещё не заведена (bootloader — реализован, Фазы 06, см. запись ниже).
- Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log. - Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log (пример — точечный патч `fault_injection_hardening.c` под `#if defined(__arm__)`, bootloader Фаза 3, см. запись ниже).
- Отслеживать мерж ветки `feature-tui-monolith` в `dev` — после мержа эту запись нужно закрыть датой и финальным диапазоном SHA. - ~~Отслеживать мерж ветки `feature-tui-monolith` в `dev`~~ — смёржено (`b4c664f`), запись закрыта датой ниже.
- Отслеживать тег `bootloader-v*` — после первого релиза закрыть запись «bootloader: полная реализация» датой и финальным SHA (сейчас часть диапазона — незакоммиченные изменения рабочего дерева).
## [Не выпущено] — service-tui: монолитный spsdk-бэкенд, устойчивость к обрыву USB, упаковка PyInstaller ## [Не выпущено] — bootloader: полная реализация, Фазы 06 (MCUboot Direct-XIP, HAB, service-tui интеграция)
Диапазон: `1801f1beb959d610d31ee3dcd1f91046953117d4..22c40779ef0ec9911031d7a5272c4611b596d3e8` + незакоммиченные изменения рабочего дерева (документация) Диапазон: `4644f21507d3..6c564f16388a` + незакоммиченные изменения рабочего дерева (HAB-подпись
тестовым ключом, Тир-0/Тир-1 верификация в service-tui, CI `bootloader-v*`, документация)
Сравнение: <https://github.com/OSabuser/tft_manufacture_test/compare/4644f21...6c564f1>
> `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()`
(таксономия отказов AD); 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`.
- Полный аппаратный чек-лист пройден на каждой фазе (Фазы 26); детали были в удалённых
`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`
Сравнение: <https://github.com/OSabuser/tft_manufacture_test/compare/8869b3c...d9fb813>
### Кратко
- Первый тег-релиз проекта: `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`)
Сравнение: <https://github.com/OSabuser/tft_manufacture_test/compare/1801f1beb959d610d31ee3dcd1f91046953117d4...22c40779ef0ec9911031d7a5272c4611b596d3e8> Сравнение: <https://github.com/OSabuser/tft_manufacture_test/compare/1801f1beb959d610d31ee3dcd1f91046953117d4...22c40779ef0ec9911031d7a5272c4611b596d3e8>
> Ветка `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` + > бинарников через subprocess (`tools/host/flash_usb.py --fcb-path` +
> `nxpimage`) была реализацией на момент Фазы 0/раннего мержа и с тех пор > `nxpimage`) была реализацией на момент Фазы 0/раннего мержа и с тех пор

View file

@ -9,21 +9,21 @@
## Firmware-проекты ## Firmware-проекты
| Проект | Путь | Описание | | Проект | Путь | Описание |
| ------------------- | ---------------------- | -------------------------------------------------------------------------------------- | | ----------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Тестовая прошивка (реализована) | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS | | Тестовая прошивка | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS [README](firmware/test/README.md) |
| Загрузчик (запланирован) | `firmware/bootloader/` | A/B обновление через uSD. Обновляется только через USB ROM + blhost / SWD | | Загрузчик | `firmware/bootloader/` | A/Б обновление через uSD (MCUboot, Direct-XIP), recovery при зависании образа. Обновляется только через USB ROM + blhost / SWD [README](firmware/bootloader/README.md) |
| Production прошивка (запланирован) | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком | | Production прошивка (запланирована) | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком |
--- ---
## Инструменты (`tools/`) ## Инструменты (`tools/`)
| Инструмент | Путь | Назначение | | Инструмент | Путь | Назначение |
| ------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------- | ---------------------------------------------------------------------------------------------- |
| Сервисный TUI | `tools/service_tui/` | Диагностика и прошивка готовых плат сервисным инженером [README](tools/service_tui/README.md) | | Сервисный TUI | `tools/service_tui/` | Диагностика и прошивка готовых плат сервисным инженером [README](tools/service_tui/README.md) |
| Прошивка (dev-CLI) | `tools/host/` | USB SDP / SWD прошивка при разработке [README](tools/host/README.md) | | Прошивка (dev-CLI) | `tools/host/` | USB SDP / SWD прошивка при разработке [README](tools/host/README.md) |
| HIL-тесты | `tools/hil/` | pytest-окружение аппаратных тестов [README](tools/hil/README.md) | | HIL-тесты | `tools/hil/` | pytest-окружение аппаратных тестов [README](tools/hil/README.md) |
--- ---

View file

@ -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 и аппаратной валидации на стенде.

View file

@ -3,9 +3,8 @@
> Проект: TFT Firmware (MIMXRT1052CVJ5B) > Проект: TFT Firmware (MIMXRT1052CVJ5B)
> Документ описывает схему и принцип работы двух GitHub Actions workflow в > Документ описывает схему и принцип работы двух GitHub Actions workflow в
> репозитории: `ci.yml` (обычный PR/push-цикл) и `release.yml` (публикация > репозитории: `ci.yml` (обычный PR/push-цикл) и `release.yml` (публикация
> релизных бинарников по тегу). Для истории решений и roadmap развития > релизных бинарников по тегу). Это техническая справка «как оно работает
> `ci.yml` — см. `just/ci_workflow.md`; этот документ — техническая справка > сейчас», а не хронология решений.
> «как оно работает сейчас», а не хронология.
--- ---
@ -13,15 +12,14 @@
| | `ci.yml` | `release.yml` | | | `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-тесты | Собираются ли и публикуются ли релизные бинарники | | Что проверяет | Собирается ли проект и проходят ли host-тесты | Собираются ли и публикуются ли релизные бинарники |
| Публикует что-то наружу? | Нет — только артефакты прогона (для отладки) | Да — GitHub Release с реальными asset'ами (только по тегу) | | Публикует что-то наружу? | Нет — только артефакты прогона (для отладки) | Да — GitHub Release с реальными asset'ами (только по тегу) |
| Раннеры | `ubuntu-latest` (оба job'а) | `ubuntu-latest` + `macos-latest` + `windows-latest` | | Раннеры | `ubuntu-latest` (оба job'а) | `ubuntu-latest` + `macos-latest` + `windows-latest` |
Они намеренно не смешаны в один файл (см. `just/ci_workflow.md`, «Шаг 3 — Они намеренно не смешаны в один файл: PR-цикл должен оставаться быстрым и не
выделить release workflow»): PR-цикл должен оставаться быстрым и не зависеть зависеть от кросс-платформенной упаковки `service-tui`, а релизная
от кросс-платформенной упаковки `service-tui`, а релизная публикация не публикация не должна гонять host-тесты повторно на каждый push в PR.
должна гонять host-тесты повторно на каждый push в PR.
--- ---
@ -57,8 +55,7 @@ bash -lc '...'` — `--user root` обязателен, иначе non-root по
Чего `ci.yml` **не делает**: lint (заглушка в `just ci::lint`), coverage, Чего `ci.yml` **не делает**: lint (заглушка в `just ci::lint`), coverage,
сборку/упаковку `service-tui`, HIL-тесты (нужно физическое железо — сборку/упаковку `service-tui`, HIL-тесты (нужно физическое железо —
самостоятельная задача для self-hosted раннера). См. `just/ci_workflow.md` самостоятельная задача для self-hosted раннера). См. §5 ниже.
для планов по каждому из этих пунктов.
--- ---
@ -66,19 +63,21 @@ bash -lc '...'` — `--user root` обязателен, иначе non-root по
### 3.1 Схема тегов ### 3.1 Схема тегов
Firmware (`firmware_test`) и `service-tui` версионируются и релизятся Firmware (`firmware_test`), bootloader и `service-tui` версионируются и
**независимо** (`FIRST_RELEASE_PLAN.md`, Шаг 2.1) — два разных паттерна релизятся **независимо** — три разных паттерна тега запускают три разных
тега запускают два разных сценария внутри одного workflow-файла: сценария внутри одного workflow-файла:
```mermaid ```mermaid
flowchart TD flowchart TD
push_fw["push tag\nfirmware-vX.Y.Z"] --> firmware 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 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 -->|"тег 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)"| macos["service-tui-macos\njust host::package-tui"]
firmware -->|"тег tui-v* ИЛИ\ndispatch(tui)"| windows["service-tui-windows\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 windows -->|"тег tui-v*"| publishTui
``` ```
Ключевое архитектурное решение: **HAB-образ `firmware_test`, который Ключевое архитектурное решение: **HAB-образы `firmware_test` и `bootloader`,
вшивается внутрь TUI-бандла, всегда собирается заново из текущего HEAD** которые вшиваются внутрь TUI-бандла, всегда собираются заново из текущего
джобой `firmware`а не скачивается из последнего опубликованного HEAD** джобой `firmware`а не скачиваются из последних опубликованных
`firmware-v*` релиза. Поэтому job `firmware` выполняется **при любом `firmware-v*`/`bootloader-v*` релизов. Поэтому job `firmware` выполняется
триггере**, без условия — она нужна и для отдельного firmware-релиза, и **при любом триггере**, без условия — она нужна и для отдельных релизов
как зависимость для упаковки TUI. firmware_test/bootloader, и как общая зависимость для упаковки TUI (см.
Фазу 5, `firmware/bootloader/PLAN.md` — до неё `firmware` собирала только
firmware_test, и TUI-бандл молча уходил без образа bootloader).
### 3.2 Триггеры ### 3.2 Триггеры
```yaml ```yaml
on: on:
push: push:
tags: ["tui-v*", "firmware-v*"] tags: ["tui-v*", "firmware-v*", "bootloader-v*"]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release_type: {type: choice, options: [tui, firmware], default: tui} release_type: {type: choice, options: [tui, firmware, bootloader], default: tui}
``` ```
`workflow_dispatch` — «сухой прогон» без публикации: собирает всё `workflow_dispatch` — «сухой прогон» без публикации: собирает всё
@ -125,15 +126,19 @@ job-level `if:`, использовать именно эту форму.
| Job | Раннер | Когда выполняется | Что делает | | 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 | | `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-<os>/` в 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-<os>/` в 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` | | `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) — **Debug HAB для firmware_test, Release HAB для bootloader** — не единое
Release-сборка `firmware_test` нестабильна (FCB/clock), поэтому `firmware` правило «всегда Debug». Release-сборка `firmware_test` нестабильна
джоба собирает только `hab-firmware-test-debug`, не полный (FCB/clock), поэтому `firmware`-джоба собирает `hab-firmware-test-debug`,
`hab-all-release`. не полный `hab-all-release`. Bootloader — наоборот: production-путь (Фаза 5,
`firmware/bootloader/PLAN.md`) жёстко требует **Release**, подписанный
(`flags=0x08`) — Debug-конфиг bootloader остаётся unsigned и используется
только для локальной отладки, в релиз/TUI-бандл не попадает.
**Сверка версии тег↔файл** — маленький, но важный guard в обеих ветках **Сверка версии тег↔файл** — маленький, но важный guard в обеих ветках
(`firmware`/`service-tui-*`): если версия в теге не совпадает с версией в (`firmware`/`service-tui-*`): если версия в теге не совпадает с версией в
@ -152,6 +157,7 @@ Git Bash на Windows.
```bash ```bash
gh workflow run release.yml --ref dev -f release_type=tui 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=firmware
gh workflow run release.yml --ref dev -f release_type=bootloader
``` ```
или через веб-интерфейс: Actions → **Release****Run workflow** → выбрать или через веб-интерфейс: Actions → **Release****Run workflow** → выбрать
@ -180,10 +186,13 @@ branch и `release_type`. Джобы `publish-*` в этом сценарии п
- **Self-hosted HIL-раннер.** Ни один из двух workflow не может - **Self-hosted HIL-раннер.** Ни один из двух workflow не может
задетектировать SDP на живой плате или прогнать деструктивные сценарии задетектировать SDP на живой плате или прогнать деструктивные сценарии
(обрыв USB) — GitHub-hosted раннеры не видят реальное железо. Это ручной (обрыв USB) — GitHub-hosted раннеры не видят реальное железо. Это ручной
шаг перед каждым релизом (`FIRST_RELEASE_PLAN.md`, Шаг 4), пока не шаг перед каждым релизом, пока не поднят self-hosted lane.
поднят self-hosted lane (`just/ci_workflow.md`, «Шаг 5»). - **`lint`/`coverage`** не подключены ни в `ci.yml`, ни в `release.yml`.
- **`lint`/`coverage`** не подключены ни в `ci.yml`, ни в `release.yml`
см. `just/ci_workflow.md`, «Рекомендуемые следующие шаги» (Шаги 12).
- **Публикация devcontainer image в GHCR** — образ пересобирается в каждой - **Публикация devcontainer image в GHCR** — образ пересобирается в каждой
job'е каждого workflow (пусть и с layer-кэшем); заранее опубликованный 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 пока не встроена (сама
церемония — не автоматизируемый процесс, см. документ).

View file

@ -68,7 +68,7 @@ graph TB
end end
``` ```
--- ---
## 3. Что устанавливается и где ## 3. Что устанавливается и где
@ -198,9 +198,9 @@ flowchart LR
│ │ │ dcd.bin, ivt_flashloader.bin │ │ │ dcd.bin, ivt_flashloader.bin
│ │ └── uv.lock │ │ └── 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-окружение │ └── hil/ ← HIL pytest-окружение
│ ├── conftest.py ← фикстуры: m5, loaded_<n>, uart_<n> │ ├── conftest.py ← фикстуры: m5, loaded_<n>, uart_<n>
@ -356,8 +356,8 @@ buildPresets (HIL):
| Прошивка | Стратегия | Инструмент загрузки | | Прошивка | Стратегия | Инструмент загрузки |
| ---------------------------- | ---------------------------------- | ------------------- | | ---------------------------- | ---------------------------------- | ------------------- |
| `firmware_test` | XIP из Flash (`flexspi_nor.ld`) | SPSDK → Flash | | `firmware_test` | XIP из Flash (`flexspi_nor.ld`) | SPSDK → Flash |
| `bootloader` | Копирование в ITCM | SPSDK → Flash | | `bootloader` | XIP из Flash, без ITCM/DCD (не трогает SDRAM) — выбирает и запускает `tft_app` из слота (MCUboot Direct-XIP) | SPSDK → Flash |
| `tft_app` | XIP + буферы в SDRAM | SPSDK → Flash | | `tft_app` | XIP из своего слота (Direct-XIP, два слота A/Б) + буферы в SDRAM (SEMC поднимает сама) | SPSDK → Flash |
| HIL target (`tests/target/`) | Исполнение из ITCM/DTCM (`ram.ld`) | pyOCD → RAM | | 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 не используется — прошивка исчезает при отключении питания. **HIL boot-стратегия:** pyOCD настраивает FLEXRAM (128 KB ITCM + 128 KB DTCM + 256 KB OCRAM), записывает PT_LOAD сегменты ELF по физическим адресам, устанавливает SP/PC из таблицы векторов и запускает выполнение. Flash не используется — прошивка исчезает при отключении питания.

View file

@ -93,7 +93,7 @@ in-process через Python API `spsdk` (`app/flash_backend.py`: `HabImage`,
`configure-memory 0xF000000F`) — auto-config для 4-байтной адресации не `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`, три сборки этого репозитория, что через §8. Штатный путь (`--firmware`, три сборки этого репозитория, что через
`just host::flash`, что через `service-tui`) не меняется и по-прежнему `just host::flash`, что через `service-tui`) не меняется и по-прежнему
использует auto-config Flashloader, как описано в 1.4. использует auto-config Flashloader, как описано в 1.4.

View file

@ -84,9 +84,9 @@ typedef struct {
DCD содержит последовательность команд двух типов: DCD содержит последовательность команд двух типов:
| Команда | Тег | Назначение | | Команда | Тег | Назначение |
|---|---|---| | ---------------- | ------ | -------------------------------- |
| `Write Data` | `0xCC` | Записать значение по адресу | | `Write Data` | `0xCC` | Записать значение по адресу |
| `Check Bits Set` | `0xCF` | Ждать пока бит станет `1` (poll) | | `Check Bits Set` | `0xCF` | Ждать пока бит станет `1` (poll) |
Формат бинарника: `Tag(1) | Length(2 BE) | Parameter(1) | данные`. Формат бинарника: `Tag(1) | Length(2 BE) | Parameter(1) | данные`.
@ -105,11 +105,11 @@ DCD содержит последовательность команд двух
### 4.1 По типу образа ### 4.1 По типу образа
| Режим | `flags` | CSF | Применение | | Режим | `flags` | CSF | Применение |
|---|---|---|---| | ------------------ | ------- | ------------------------- | ------------------- |
| Unsigned | `0x00` | отсутствует | разработка, отладка | | Unsigned | `0x00` | отсутствует | разработка, отладка |
| Signed | `0x08` | RSA/ECDSA подпись | производство | | Signed | `0x08` | RSA/ECDSA подпись | производство |
| Signed + Encrypted | `0x0C` | подпись + шифрование кода | максимальная защита | | Signed + Encrypted | `0x0C` | подпись + шифрование кода | максимальная защита |
### 4.2 По состоянию чипа (OTP fuse) ### 4.2 По состоянию чипа (OTP fuse)
@ -150,6 +150,42 @@ BootROM проверяет подпись, но **игнорирует ошиб
→ прыгает на entry point → прыгает на 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 <project> <debug|release>`
(обёртка над `nxpimage hab parse`) — для подписанного образа поле `csf` в IVT ненулевое и рядом
появляется отдельный `csf.bin`; для unsigned (`flags=0x00`) оба отсутствуют.
--- ---
## 6. Почему Unsigned-образ требует HAB-контейнер ## 6. Почему Unsigned-образ требует HAB-контейнер
@ -170,7 +206,7 @@ BootROM проверяет подпись, но **игнорирует ошиб
BootROM в режиме SDP умеет только писать в RAM и прыгать. Для записи во Flash необходим **Flashloader** — специальная программа от NXP. BootROM в режиме SDP умеет только писать в RAM и прыгать. Для записи во Flash необходим **Flashloader** — специальная программа от NXP.
``` ```bash
Плата в SDP режиме (BOOT_MOD_1 = 3V3) Плата в SDP режиме (BOOT_MOD_1 = 3V3)
│ sdphost -u 0x1FC9,0x0130 │ sdphost -u 0x1FC9,0x0130
@ -192,12 +228,12 @@ BootROM в режиме SDP умеет только писать в RAM и пр
## 8. Жизненный цикл для проекта TFT ## 8. Жизненный цикл для проекта TFT
| Стадия | Режим HAB | Подпись | Fuse | | Стадия | Режим HAB | Подпись | Fuse |
|---|---|---|---| | --------------------- | ---------- | ----------------- | ---------------- |
| Разработка | Open | Unsigned (`0x00`) | не трогаем | | Разработка | Open | Unsigned (`0x00`) | не трогаем |
| Входной контроль | Open | Unsigned (`0x00`) | не трогаем | | Входной контроль | Open | Unsigned (`0x00`) | не трогаем |
| Предсерийные образцы | Open | Signed (`0x08`) | не трогаем | | Предсерийные образцы | Open | Signed (`0x08`) | не трогаем |
| Серийное производство | **Closed** | Signed (`0x08`) | `SEC_CONFIG = 1` | | Серийное производство | **Closed** | Signed (`0x08`) | `SEC_CONFIG = 1` |
> ⚠️ Запись `SEC_CONFIG = 1` необратима. Перед закрытием HAB необходимо убедиться, что подписанный образ успешно проходит верификацию на реальном железе. > ⚠️ Запись `SEC_CONFIG = 1` необратима. Перед закрытием HAB необходимо убедиться, что подписанный образ успешно проходит верификацию на реальном железе.
@ -205,13 +241,13 @@ BootROM в режиме SDP умеет только писать в RAM и пр
## 9. Инструменты ## 9. Инструменты
| Инструмент | Назначение | | Инструмент | Назначение |
|---|---| | --------------------- | ---------------------------------------------------------- |
| `nxpimage hab export` | Сборка HAB-образа (IVT + BDT + DCD + опционально CSF) | | `nxpimage hab export` | Сборка HAB-образа (IVT + BDT + DCD + опционально CSF) |
| `nxpimage hab parse` | Разбор готового образа для проверки | | `nxpimage hab parse` | Разбор готового образа для проверки |
| `sdphost` | Связь с BootROM в SDP режиме (запись в RAM, прыжок) | | `sdphost` | Связь с BootROM в SDP режиме (запись в RAM, прыжок) |
| `blhost` | Связь с Flashloader (запись во Flash, конфигурация памяти) | | `blhost` | Связь с Flashloader (запись во Flash, конфигурация памяти) |
| `nxpdevscan` | Обнаружение подключённых NXP устройств | | `nxpdevscan` | Обнаружение подключённых NXP устройств |
--- ---

View file

@ -3,7 +3,7 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project( project(
bootloader bootloader
VERSION 0.1.0 VERSION 1.0.0
LANGUAGES C ASM) LANGUAGES C ASM)
set(TARGET_NAME bootloader) 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) "${CMAKE_CURRENT_BINARY_DIR}/generated/version.h" @ONLY)
# bootutil (MCUboot Direct-XIP) + TinyCrypt + ASN.1 — общий список с # bootutil (MCUboot Direct-XIP) + TinyCrypt + ASN.1 — общий список с
# host-тестами (tests/host/mcuboot_port/), см. firmware/bootloader/PLAN.md, # host-тестами (tests/host/mcuboot_port/), см. firmware/bootloader/PLAN.md, Фаза
# Фаза 2. # 2.
include(${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/bootutil_sources.cmake) include(${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/bootutil_sources.cmake)
# bootloader_fatfs — bare-metal FatFS для чтения TFT_APP.BIN с SD (Фаза 3). # bootloader_fatfs — bare-metal FatFS для чтения TFT_APP.BIN с SD (Фаза 3).
@ -38,7 +38,8 @@ add_executable(
${BSP_STARTUP_FILE} ${BSP_STARTUP_FILE}
${BSP_SYSCALLS_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} target_include_directories(${TARGET_NAME}
PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
@ -49,15 +50,24 @@ target_compile_definitions(
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Зависимости. bsp_button — downgrade-override (удержание BSP_BUTTON_1). # Зависимости. bsp_button — downgrade-override (удержание BSP_BUTTON_1).
# bootloader_fatfs — чтение TFT_APP.BIN с SD (Фаза 3, firmware/bootloader/fatfs/). # bootloader_fatfs — чтение TFT_APP.BIN с SD (Фаза 3,
# bsp_sdram — smoke-test SDRAM/SEMC (Фаза 4, bsp_sdram_configure()+_init()) — # firmware/bootloader/fatfs/). bsp_sdram — smoke-test SDRAM/SEMC (Фаза 4,
# bootloader без DCD, сам поднимает SEMC на время диагностики. # bsp_sdram_configure()+_init()) — bootloader без DCD, сам поднимает SEMC на
# время диагностики.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick target_link_libraries(
bsp_usb_cdc bsp_qspi_flash ${TARGET_NAME}
bsp_boot_xip_no_dcd bsp_button PRIVATE bsp_board
bsp_wdog bsp_boot_state bsp_led
bsp_sdram bootloader_fatfs) 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): # [DEV-ONLY] Диагностика Фазы 4 — CLI-команда "sdram_test" (dev_sdram_test.c):
@ -72,9 +82,9 @@ if(CMAKE_BUILD_TYPE STREQUAL "Debug")
endif() endif()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом # Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом bootloader
# bootloader (256 KB, см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). Без SDRAM # (256 KB, см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). Без SDRAM (в отличие от
# (в отличие от firmware_test) — bootloader SDRAM не использует. # firmware_test) — bootloader SDRAM не использует.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
target_link_options( target_link_options(
${TARGET_NAME} ${TARGET_NAME}

View file

@ -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) — не оставлять плату/дерево
с искусственно применённым порогом.

View file

@ -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 жив) |
---

View file

@ -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.19.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/<SD>/TFT_APP.BIN
# Сценарий 2/3 (Slot Б уже занят под v2 — целевой слот снова А, т.к. Slot Б активен) —
# тот же stub_a, сценарии отличаются только удержанием кнопки, см. §7 и таблицу §8.
cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes/<SD>/TFT_APP.BIN
# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED).
# Линковка тут неважна — кандидат отклоняется до записи/выбора, ни разу не исполняется.
cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/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 (сценарии 15) как есть — все должны проходить без единого неожиданного
сброса. 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 | Сброса нет, отладка не сбивается |
---

View file

@ -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":<bool>,"reset_count":<N>,"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 |

View file

@ -232,7 +232,7 @@ hab-verify project="firmware_test" type="release":
exit 1 ;; exit 1 ;;
esac esac
OUT="/tmp/hab_parse_{{ project }}_{{ type }}.yaml" 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}" echo " ✅ ${OUT}"
grep -E "(entry|csf|tag)" "${OUT}" || true grep -E "(entry|csf|tag)" "${OUT}" || true

View file

@ -604,6 +604,16 @@ package-tui:
echo " Бандл TUI без Debug-образа нерабочий (см. README/DEV_ARCH.md)." echo " Бандл TUI без Debug-образа нерабочий (см. README/DEV_ARCH.md)."
exit 1 exit 1
fi 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/') VERSION=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"(.+)".*/\1/')
case "$(uname -s)" in case "$(uname -s)" in

View file

@ -17,7 +17,7 @@ tools/host/
├── flash_swd.py — прошивка через SWD: FCB + HAB → pyOCD → Flash ├── flash_swd.py — прошивка через SWD: FCB + HAB → pyOCD → Flash
├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип; ├── hab/ — HAB yaml-конфиги для nxpimage (по одному на проект × тип;
│ service-tui генерирует такие же временно, на лету — │ service-tui генерирует такие же временно, на лету —
│ см. tools/service_tui/DEV_ARCH.md, §8) │ см. tools/service_tui/docs/ARCHITECTURE.md, §8)
├── dcd/ ├── dcd/
│ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP) │ ├── ivt_flashloader.bin — NXP Flashloader (загружается в RAM через SDP)
│ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P) │ ├── dcd.bin — DCD: инициализация SDRAM (SEMC + MT48LC16M16A2P)
@ -32,7 +32,7 @@ tools/host/
> Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся > Все бинарники в `dcd/` получены из NXP SecureProvisioningTool и хранятся
> в репозитории — пересоздавать не нужно. `w25q128`/`w25q512` — единственные > в репозитории — пересоздавать не нужно. `w25q128`/`w25q512` — единственные
> два варианта в реальном использовании (64 и 256 сведены к ним же, см. > два варианта в реальном использовании (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, Прошивка сторонних/легаси бинарников с нестандартной памятью (явный FCB,
без auto-config) — через `service-tui` (`tools/service_tui/`), не напрямую без 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.
--- ---

View file

@ -1,11 +1,16 @@
# ============================================================================= # =============================================================================
# HAB Container — firmware/bootloader # HAB Container — firmware/bootloader (Release, ПОДПИСАННЫЙ — flags=0x08)
# Расположение: tools/host/hab/hab_bootloader.yaml # Расположение: 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: options:
flags: 0x00 flags: 0x08
startAddress: 0x60000000 startAddress: 0x60000000
ivtOffset: 0x1000 ivtOffset: 0x1000
initialLoadSize: 0x2000 initialLoadSize: 0x2000
@ -14,4 +19,21 @@ options:
inputImageFile: "../../../build/Release/bootloader.bin" inputImageFile: "../../../build/Release/bootloader.bin"
sections: [] 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"

View file

@ -9,7 +9,7 @@
| 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) | | 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) | | 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) | | 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) | | 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 **Верификация**: чистая плата → `just host::flash-production`-путь для bootloader (или его bootloader-only
подмножество) → service-tui показывает "bootloader alive" на основе реального ping/version с платы. подмножество) → 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) ## Фаза 6 — Устойчивость и восстановление (recovery)

View file

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

View file

@ -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-ключей реально фузить (14)?** Рекомендация: **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 <VID:PID> -- efuse-program-once <ПРОВЕРЕННЫЙ_АДРЕС> <hex(srk_hash.bin)>
```
- Программируется побитово, каждый бит — один раз. Ошибка не устраняется повторной записью.
- Только 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.33.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/парольный менеджер) — организационное
решение, не техническое.

View file

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

View file

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

View file

@ -4,7 +4,7 @@ TUI-приложение для диагностики и прошивки пл
Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows. Написано на 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) │ │ ◉ Диагностическая прошивка (firmware_test) │
│ ○ Серийная прошивка (bootloader + tft_app) │ │ ○ Серийная прошивка (загрузчик) │
│ ○ Верификация (smoke-test, требует BOOT_MOD) │ ← только если «Серийная»
│ ○ Другое │ │ ○ Другое │
│ Файл (custom_binaries/) │ │ Файл (custom_binaries/) │
│ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое» │ [ TFT_BOOTLOADER_NEW.bin ▾ ] │ ← только если «Другое»
@ -87,18 +88,61 @@ Python API `spsdk` (без вызова внешних CLI-утилит):
1. `HabImage` (spsdk) — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM") 1. `HabImage` (spsdk) — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM")
2. в Flash пишется явный FCB под выбранную память платы (не тот же 2. в Flash пишется явный FCB под выбранную память платы (не тот же
auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`) W25Q256/512 он ненадёжен, см. `docs/ARCHITECTURE.md`)
3. образ прошивается с `0x60001000`, как обычно 3. образ прошивается с `0x60001000`, как обычно
**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не **Выбор запоминается на весь запуск TUI** — файл, память платы, DCD и чек-бокс
нужно выставлять заново на каждой следующей плате: прошили одну, вынули «Верификация» не нужно выставлять заново на каждой следующей плате: прошили
USB, вставили следующую такую же — TUI уже подставила прошлый выбор, одну, вынули USB, вставили следующую такую же — TUI уже подставила прошлый
остаётся нажать "Загрузить". Сбрасывается только при перезапуске 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) ### Переход в рабочий режим (PostFlashScreen)
Показывается **только** после успешной прошивки `firmware_test` (для Показывается **только** после успешной прошивки `firmware_test` (для
Production/Custom этот шаг не нужен). Production/Custom этот шаг не нужен — у Production при включённой
верификации свой отдельный экран, см. `VerifyScreen` выше).
```bash ```bash
┌────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────┐
@ -139,6 +183,7 @@ Production/Custom этот шаг не нужен).
``` ```
Что важно знать: Что важно знать:
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать - **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
все"/"Снять все". все"/"Снять все".
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена - **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
@ -210,7 +255,7 @@ Production/Custom этот шаг не нужен).
нельзя идентифицировать по UID — авто-прошивка по факту детекта без нельзя идентифицировать по UID — авто-прошивка по факту детекта без
подтверждения оператора убрала бы последний шанс заметить, что в руках подтверждения оператора убрала бы последний шанс заметить, что в руках
не та плата. Массового программирования (несколько плат параллельно) не та плата. Массового программирования (несколько плат параллельно)
нет и не планируется в этом виде — см. `docs/DEV_ARCH.md`, §8. нет и не планируется в этом виде — см. `docs/ARCHITECTURE.md`, §8.
- **Циклический прогон тестов** (повторный автозапуск набора без ручного - **Циклический прогон тестов** (повторный автозапуск набора без ручного
нажатия на каждую итерацию) — отложен на пост-релиз, не входит в текущую нажатия на каждую итерацию) — отложен на пост-релиз, не входит в текущую
версию. версию.
@ -220,11 +265,18 @@ Production/Custom этот шаг не нужен).
MicroPython на M5 — агенту нужно время на инициализацию I2C/AW9523/CAN MicroPython на M5 — агенту нужно время на инициализацию I2C/AW9523/CAN
перед готовностью отвечать). WaitingScreen в это время показывает перед готовностью отвечать). WaitingScreen в это время показывает
«Плата найдена, подключаемся...» — это штатное поведение, не зависание. «Плата найдена, подключаемся...» — это штатное поведение, не зависание.
См. `docs/DEV_ARCH.md`, §10. См. `docs/ARCHITECTURE.md`, §10.
- **macOS-бандл не подписан Apple Developer ID и не нотаризован** — при - **macOS-бандл не подписан Apple Developer ID и не нотаризован** — при
первом запуске Gatekeeper блокирует каждый файл бандла по отдельности. первом запуске Gatekeeper блокирует каждый файл бандла по отдельности.
Обход — `xattr -cr` на распакованную папку, см. «Запуск» → «macOS: Обход — `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/` ни spsdk Python API (`app/flash_backend.py`), без вызова `tools/host/` ни
субпроцессом, ни как-либо иначе. Не нужны ни Zadig, ни установленный субпроцессом, ни как-либо иначе. Не нужны ни Zadig, ни установленный
Python/uv на машине сервисника. Структура бандла и резолв путей во frozen — Python/uv на машине сервисника. Структура бандла и резолв путей во frozen —
см. [DEV_ARCH.md](docs/DEV_ARCH.md), §14. см. [ARCHITECTURE.md](docs/ARCHITECTURE.md), §14.
#### macOS: первый запуск («Apple could not verify...» на каждый файл) #### macOS: первый запуск («Apple could not verify...» на каждый файл)
@ -314,13 +366,13 @@ xattr -cr service-tui-vX.Y.Z-macos/
## Зависимости ## Зависимости
| Пакет | Версия | Назначение | | Пакет | Версия | Назначение |
| --------------- | ------- | -------------------------------------------------------- | | --------------- | ------ | ----------------------------------------------------------------------------------------------------------------- |
| `textual` | ≥ 0.80 | TUI фреймворк | | `textual` | ≥ 0.80 | TUI фреймворк |
| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial | | `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial |
| `spsdk` | 3.7.0 | прошивка: SDP/McuBoot (Flash, chip erase) + HabImage (сборка кастомных HAB), нативный HID (libusbsio) — без Zadig | | `spsdk` | 3.7.0 | прошивка: SDP/McuBoot (Flash, chip erase) + HabImage (сборка кастомных HAB), нативный HID (libusbsio) — без Zadig |
| `python-dotenv` | ≥ 1.0 | загрузка `.env` | | `python-dotenv` | ≥ 1.0 | загрузка `.env` |
| `pyinstaller` | ≥ 6.0 | сборка standalone-бандла (`just host::package-tui`) | | `pyinstaller` | ≥ 6.0 | сборка standalone-бандла (`just host::package-tui`) |
**Никакой рантайм-зависимости на `tools/host/` в смысле subprocess-вызовов **Никакой рантайм-зависимости на `tools/host/` в смысле subprocess-вызовов
нет.** Прошивка выполняется in-process через `spsdk` (`app/flash_backend.py`). нет.** Прошивка выполняется 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 `*_fdcb.bin`, `ivt_flashloader.bin`) — они отслеживаются в git, `just
host::setup-tools` для запуска TUI из исходников не требуется. `tools/host/ host::setup-tools` для запуска TUI из исходников не требуется. `tools/host/
flash_usb.py` — независимый dev-CLI для `just host::flash*`, TUI его не 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).
--- ---

View file

@ -19,7 +19,13 @@ from textual.binding import Binding
from .firmware_client import FirmwareClient from .firmware_client import FirmwareClient
from .m5_client import M5Client from .m5_client import M5Client
from .models import AppMode, FlashPreset, FlashTarget 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__) logger = logging.getLogger(__name__)
@ -82,7 +88,8 @@ class ServiceApp(App):
""" """
После прошивки: После прошивки:
- firmware_test + успех PostFlashScreen (промпт смены BootMode) - firmware_test + успех PostFlashScreen (промпт смены BootMode)
- production/custom + успех WaitingScreen - production + успех + preset.verify VerifyScreen (Тир-1, Фаза 5)
- production (без verify) / custom + успех WaitingScreen
- target=None обрыв USB (watcher в простое ИЛИ backend во время - target=None обрыв USB (watcher в простое ИЛИ backend во время
активной операции, см. models.FlashResult, Фаза 4a вариант 2) активной операции, см. models.FlashResult, Фаза 4a вариант 2)
WaitingScreen с причиной (конкретный текст, если есть, иначе WaitingScreen с причиной (конкретный текст, если есть, иначе
@ -102,9 +109,21 @@ class ServiceApp(App):
if event.success and event.target == FlashTarget.FIRMWARE_TEST: if event.success and event.target == FlashTarget.FIRMWARE_TEST:
self.switch_screen(PostFlashScreen()) 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: else:
self._switch_to_waiting() self._switch_to_waiting()
@on(VerifyScreen.Done)
def _on_verify_done(self) -> None:
"""Оператор завершил/пропустил верификацию загрузчика (Тир-1)."""
self._switch_to_waiting()
@on(PostFlashScreen.Done) @on(PostFlashScreen.Done)
def _on_post_flash_done(self) -> None: def _on_post_flash_done(self) -> None:
"""Оператор подтвердил смену BootMode или истёк таймаут.""" """Оператор подтвердил смену BootMode или истёк таймаут."""

View file

@ -123,6 +123,24 @@ AppFrame {
margin-right: 1; 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 { #flash-btn-row {
height: auto; height: auto;
margin-top: 1; margin-top: 1;
@ -201,6 +219,74 @@ AppFrame {
width: 100%; width: 100%;
height: auto; 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 ─────────────────────────────────────────── */ /* ── DiagScreen ─────────────────────────────────────────── */
#diag-frame { #diag-frame {

View file

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

View file

@ -349,6 +349,16 @@ class HabBuildError(FlashBackendError):
"""Ошибка сборки HAB-образа через HabImage (см. build_custom_hab).""" """Ошибка сборки HAB-образа через HabImage (см. build_custom_hab)."""
class FlashVerifyError(FlashBackendError):
"""Тир-0: readback записанного диапазона не совпал с исходным образом.
Логическая ошибка (плата на месте, но байты во Flash не те, что писали
редкий silent-corruption, не пойманный кодом статуса самой write-команды).
connection_lost наследуется False UI остаётся на экране и показывает
сообщение, не уходит на WaitingScreen (вариант 2, Гейт 4a).
"""
# SPSDKTimeoutError НЕ наследует SPSDKConnectionError (оба — потомки SPSDKError, # SPSDKTimeoutError НЕ наследует SPSDKConnectionError (оба — потомки SPSDKError,
# проверено по исходникам spsdk 3.7.0), поэтому один `except SPSDKConnectionError` # проверено по исходникам spsdk 3.7.0), поэтому один `except SPSDKConnectionError`
# его пропускал → safety net в Flasher показывал «Непредвиденная ошибка» вместо # его пропускал → 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) 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( def _emit(
progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str
) -> None: ) -> None:
@ -638,6 +679,7 @@ def flash(
*, *,
ram_only: bool = False, ram_only: bool = False,
fcb_path: Optional[Path] = None, fcb_path: Optional[Path] = None,
verify_readback: bool = True,
progress_cb: Optional[ProgressCallback] = None, progress_cb: Optional[ProgressCallback] = None,
) -> None: ) -> None:
"""Прошить HAB-образ в Flash либо загрузить в RAM (см. flash_usb.py::flash). """Прошить HAB-образ в Flash либо загрузить в RAM (см. flash_usb.py::flash).
@ -647,7 +689,14 @@ def flash(
:param ram_only: Загрузить в RAM через SDP, во Flash не писать. :param ram_only: Загрузить в RAM через SDP, во Flash не писать.
:param fcb_path: Явный FCB-блоб (custom-бинари). None auto-config :param fcb_path: Явный FCB-блоб (custom-бинари). None auto-config
(write_fcb_auto, только для штатных firmware_test/production). (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 FlashBackendError: и подклассы на любой ошибке.
:raises FlashVerifyError: readback не совпал с образом (плата на месте).
:raises ConnectionLostError: обрыв USB посреди операции. :raises ConnectionLostError: обрыв USB посреди операции.
""" """
if not hab_bin.exists(): 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") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) mboot.reset(reopen=False)
except _CONNECTION_LOST_EXCEPTIONS as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:

View file

@ -262,20 +262,33 @@ class Flasher:
) )
elif target == FlashTarget.PRODUCTION: 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, flash_backend.flash,
_firmware_hab_path("bootloader"), hab_bin,
async_progress_cb=progress_cb, async_progress_cb=progress_cb,
sync_progress_cb=sync_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: elif target == FlashTarget.CUSTOM:
if bin_path is None: if bin_path is None:

View file

@ -33,7 +33,7 @@ class FlashTarget(Enum):
"""Что прошиваем.""" """Что прошиваем."""
FIRMWARE_TEST = "firmware_test" FIRMWARE_TEST = "firmware_test"
PRODUCTION = "production" # bootloader + tft_app PRODUCTION = "production" # сценарий A: только загрузчик (бандл B — будущее, см. flasher.py)
CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно CUSTOM = "custom" # произвольный HAB-бинарь, путь задаётся отдельно
@ -72,12 +72,18 @@ class FlashPreset:
выбор был неверным). выбор был неверным).
DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM. DCD/FCB-поля имеют смысл только при target == FlashTarget.CUSTOM.
verify имеет смысл только при target == FlashTarget.PRODUCTION.
""" """
target: FlashTarget = FlashTarget.FIRMWARE_TEST target: FlashTarget = FlashTarget.FIRMWARE_TEST
custom_bin_name: Optional[str] = None custom_bin_name: Optional[str] = None
use_dcd: bool = False use_dcd: bool = False
fcb_variant: FcbVariant = FcbVariant.W25Q128 fcb_variant: FcbVariant = FcbVariant.W25Q128
# Тир-1 (Фаза 5): после серийной прошивки прогнать живую проверку загрузчика
# (smoke-test SDRAM + идентификация QSPI по CDC). Требует смены BOOT_MOD +
# reset — раздражает при массовой заливке, поэтому OFF по умолчанию. Тир-0
# (readback записи) выполняется всегда, независимо от этого флага.
verify: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)

View file

@ -3,6 +3,13 @@
from .diag import DiagScreen from .diag import DiagScreen
from .flash import FlashScreen from .flash import FlashScreen
from .post_flash import PostFlashScreen from .post_flash import PostFlashScreen
from .verify import VerifyScreen
from .waiting import WaitingScreen from .waiting import WaitingScreen
__all__ = ["WaitingScreen", "FlashScreen", "PostFlashScreen", "DiagScreen"] __all__ = [
"WaitingScreen",
"FlashScreen",
"PostFlashScreen",
"VerifyScreen",
"DiagScreen",
]

View file

@ -120,7 +120,7 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
value=self._preset.target == FlashTarget.FIRMWARE_TEST, value=self._preset.target == FlashTarget.FIRMWARE_TEST,
) )
yield RadioButton( yield RadioButton(
"Серийная прошивка (bootloader + tft_app)", "Серийная прошивка (загрузчик)",
id="radio-production", id="radio-production",
value=self._preset.target == FlashTarget.PRODUCTION, value=self._preset.target == FlashTarget.PRODUCTION,
) )
@ -129,6 +129,20 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
id="radio-custom", id="radio-custom",
value=self._preset.target == FlashTarget.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 is_custom = self._preset.target == FlashTarget.CUSTOM
with Vertical( with Vertical(
id="flash-custom-group", id="flash-custom-group",
@ -197,9 +211,14 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
@on(RadioSet.Changed, "#flash-radio") @on(RadioSet.Changed, "#flash-radio")
def _on_radio_changed(self, event: RadioSet.Changed) -> None: def _on_radio_changed(self, event: RadioSet.Changed) -> None:
is_custom = event.pressed.id == "radio-custom" self._toggle_group("#flash-custom-group", event.pressed.id == "radio-custom")
group = self.query_one("#flash-custom-group") self._toggle_group(
if is_custom: "#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") group.remove_class("hidden")
else: else:
group.add_class("hidden") group.add_class("hidden")
@ -220,6 +239,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
use_dcd=self._current_use_dcd(), use_dcd=self._current_use_dcd(),
fcb_variant=self._current_fcb_variant(), fcb_variant=self._current_fcb_variant(),
) )
elif target == FlashTarget.PRODUCTION:
preset = FlashPreset(target=target, verify=self._current_verify())
else: else:
preset = FlashPreset(target=target) preset = FlashPreset(target=target)
@ -333,6 +354,9 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
def _current_use_dcd(self) -> bool: def _current_use_dcd(self) -> bool:
return self.query_one("#flash-dcd-switch", Switch).value 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: async def _on_progress(self, progress: FlashProgress) -> None:
bar = self.query_one("#flash-progress-bar", ProgressBar) bar = self.query_one("#flash-progress-bar", ProgressBar)
bar.update(total=100, progress=progress.percent) bar.update(total=100, progress=progress.percent)

View file

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

View file

@ -21,7 +21,8 @@ tools/service_tui/
├── custom_binaries/ ← runtime, gitignored, создаётся автоматически ├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое» │ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
├── tests/ ├── 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 (в релиз не идёт) ├── spike/ ← Фаза 0, де-риск spsdk API (в релиз не идёт)
└── app/ └── app/
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов ├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
@ -29,20 +30,27 @@ tools/service_tui/
├── models.py ← все типы данных (dataclass/Enum) ├── models.py ← все типы данных (dataclass/Enum)
├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen ├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8) ├── 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) ├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── usb_ports.py ← resolve_serial_port() — резолв COM/tty по VID:PID (Р8) ├── usb_ports.py ← resolve_serial_port() — резолв COM/tty по VID:PID (Р8)
├── flash_backend.py ← синхронное ядро прошивки: прямой spsdk API (McuBoot/SDP/HabImage), ├── flash_backend.py ← синхронное ядро прошивки: прямой spsdk API (McuBoot/SDP/HabImage),
│ zero Textual/asyncio импортов, тестируется без event loop │ zero Textual/asyncio импортов, тестируется без event loop.
├── flasher.py ← async-обёртка над flash_backend.py (asyncio.to_thread) │ Фаза 5: Тир-0 readback-верификация записи, всегда включена
├── flasher.py ← async-обёртка над flash_backend.py (asyncio.to_thread).
│ Фаза 5: PRODUCTION = только bootloader, Release жёстко
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты ├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
├── widgets/ ├── widgets/
│ ├── __init__.py │ ├── __init__.py
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов │ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
└── screens/ └── screens/
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen ├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen,
│ VerifyScreen, DiagScreen
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата ├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата
├── flash.py ← FlashScreen — прошивка / chip erase ├── 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 ├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
└── diag/ └── diag/
├── __init__.py ← DiagScreen — координатор диагностики ├── __init__.py ← DiagScreen — координатор диагностики
@ -130,14 +138,22 @@ stateDiagram-v2
WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC
FLASHING --> POST_FLASH : firmware_test прошит успешно 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с POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
VERIFYING --> WAITING : «Готово» / «Пропустить» / таймаут 45с (Фаза 5)
DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое
DIAGNOSING --> FLASHING: плата переведена в SDP (перемычка BOOT_MOD) 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`; переключение экранов — Состояния соответствуют `AppMode` в `models.py`; переключение экранов —
`ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на `ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на
сообщения `DeviceDetected`/`FlashDone`/`DiagDone`. сообщения `DeviceDetected`/`FlashDone`/`DiagDone`.
@ -404,6 +420,9 @@ class FlashPreset:
custom_bin_name: Optional[str] = None custom_bin_name: Optional[str] = None
use_dcd: bool = False use_dcd: bool = False
fcb_variant: FcbVariant = FcbVariant.W25Q128 fcb_variant: FcbVariant = FcbVariant.W25Q128
verify: bool = False # Фаза 5 — Тир-1 при PRODUCTION, см. §16. OFF по
# умолчанию: массовая заливка партии не должна
# требовать смены BOOT_MOD на каждой плате
``` ```
`FlashPreset` — «липкий» выбор оператора, живёт в `ServiceApp._last_flash_preset` `FlashPreset` — «липкий» выбор оператора, живёт в `ServiceApp._last_flash_preset`
@ -571,6 +590,7 @@ graph TB
WS["WaitingScreen"] WS["WaitingScreen"]
FS["FlashScreen"] FS["FlashScreen"]
PF["PostFlashScreen"] PF["PostFlashScreen"]
VS["VerifyScreen (Фаза 5)"]
DS["DiagScreen"] DS["DiagScreen"]
end end
@ -583,6 +603,7 @@ graph TB
subgraph Clients["Клиенты"] subgraph Clients["Клиенты"]
FC["FirmwareClient"] FC["FirmwareClient"]
BC["BootloaderClient (Фаза 5)\nподкласс FirmwareClient"]
M5["M5Client"] M5["M5Client"]
FL["Flasher"] FL["Flasher"]
end end
@ -590,11 +611,14 @@ graph TB
WS -->|"DeviceDetected(FLASHING)"| FS WS -->|"DeviceDetected(FLASHING)"| FS
WS -->|"DeviceDetected(DIAGNOSING)"| DS WS -->|"DeviceDetected(DIAGNOSING)"| DS
FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF
FS -->|"FlashDone(success=True, target=PRODUCTION,\npreset.verify=True)"| VS
FS -->|"FlashDone(остальное)"| WS FS -->|"FlashDone(остальное)"| WS
PF -->|"Done"| WS PF -->|"Done"| WS
VS -->|"Done"| WS
DS -->|"DiagDone(reason)"| WS DS -->|"DiagDone(reason)"| WS
FS --> FL FS --> FL
VS --> BC
DS --> OR DS --> OR
DS --> TL DS --> TL
DS --> RP DS --> RP
@ -608,6 +632,12 @@ graph TB
DS -.->|"ConnectionWatcherMixin"| FL DS -.->|"ConnectionWatcherMixin"| FL
``` ```
`VerifyScreen` намеренно **не** использует `ConnectionWatcherMixin` — та
логика предполагает уже установленное соединение, которое может разорваться,
а здесь наоборот: соединения ещё нет, экран сам поллит появление CDC (до
45с) и подключается, когда оператор физически переведёт плату в обычный
режим. См. §16.
--- ---
## 10. Жизненный цикл диагностической сессии ## 10. Жизненный цикл диагностической сессии
@ -800,10 +830,24 @@ service-tui-vX.Y.Z-<os>/
│ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data │ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data
│ └── ... ← рантайм PyInstaller, libusbsio │ └── ... ← рантайм PyInstaller, libusbsio
├── firmware/ ├── firmware/
│ └── <Type>/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/ ← пустая, создаётся оператором/автоматически └── 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 через Каждый модуль, которому нужен путь к данным, сам решает dev vs frozen через
`getattr(sys, "frozen", False)` — единообразный паттерн по всему `app/`: `getattr(sys, "frozen", False)` — единообразный паттерн по всему `app/`:
@ -823,7 +867,7 @@ service-tui-vX.Y.Z-<os>/
означает, что `libusb-1.0.*`/Zadig в бандле **не нужны** ни на Windows, ни означает, что `libusb-1.0.*`/Zadig в бандле **не нужны** ни на Windows, ни
на macOS — детект BootROM SDP и Flashloader работает из коробки. на macOS — детект BootROM SDP и Flashloader работает из коробки.
`service_tui.spec` актуализирован и ужесточён (FIRST_RELEASE_PLAN.md, Шаг 1): `service_tui.spec` актуализирован и ужесточён:
`collect_data_files("spsdk")`, `collect_dynamic_libs("libusbsio")`, `datas` `collect_data_files("spsdk")`, `collect_dynamic_libs("libusbsio")`, `datas`
для `tools/host/dcd/*.bin` (→ `data/` внутри `_internal`) и `pyproject.toml`. для `tools/host/dcd/*.bin` (→ `data/` внутри `_internal`) и `pyproject.toml`.
Сборка падает с `FileNotFoundError` уже на этапе генерации спека, если в Сборка падает с `FileNotFoundError` уже на этапе генерации спека, если в
@ -834,7 +878,7 @@ service-tui-vX.Y.Z-<os>/
`.gitignore`) — собранные бандлы это build-артефакты, не история репозитория. `.gitignore`) — собранные бандлы это build-артефакты, не история репозитория.
Пересобрано и провалидировано на живом железе macOS + Windows после Пересобрано и провалидировано на живом железе macOS + Windows после
актуализации spec (Гейт 5: детект SDP → прошивка `firmware_test` актуализации spec (Гейт 5: детект SDP → прошивка `firmware_test`
диагностика → выход) — см. `FIRST_RELEASE_PLAN.md`, Шаг 1.4. диагностика → выход).
--- ---
@ -862,16 +906,145 @@ service-tui-vX.Y.Z-<os>/
--- ---
## 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 (текущая).** Инженерные фазы 05 (backend на spsdk, - ~~Grep-зачистка устаревших docstring-упоминаний `flash_usb.py`/
обработка обрыва USB, троттлинг логов, упаковка PyInstaller) закрыты в `subprocess`~~ — перепроверено при правках Фазы 5 (bootloader
коде; `docs/DEV_ARCH.md`/`README.md` актуализированы этой правкой. Осталось `PLAN.md`): `app/flash.py` уже чист, упоминание в `flasher.py` — легитимная
по `RELEASE_ROADMAP.md` §Фаза 6: `CHANGELOG.md` (не заведён), grep-зачистка история провенанса (Фаза 2), не ошибка. Закрыто.
устаревших docstring-упоминаний `flash_usb.py`/`subprocess` в
`app/flash.py` (комментарий `_check_sdp_present`) и `flasher.py`
(docstring модуля упоминает Фазу 2 буквально, что нормально как история
провенанса, но стоит перепроверить при следующей правке этих файлов).
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на - **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
форсирует Debug через `FIRMWARE_BUILD_TYPE`. форсирует Debug через `FIRMWARE_BUILD_TYPE`.
@ -881,8 +1054,7 @@ service-tui-vX.Y.Z-<os>/
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и - Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
«копирование UID с экрана» — отложены, не начаты. «копирование UID с экрана» — отложены, не начаты.
- **POST-1 (циклический прогон неинтерактивных тестов на DiagScreen)** - **POST-1 (циклический прогон неинтерактивных тестов на DiagScreen)**
сознательно отложен на пост-релиз, вне `MONOLITH_APP_PLAN.md` (см. сознательно отложен на пост-релиз, не начат.
`RELEASE_ROADMAP.md`).
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту - **Массовое программирование** — решено НЕ делать авто-прошивку по факту
детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем
понадобится полный батч-режим — потребуется отдельный предохранитель понадобится полный батч-режим — потребуется отдельный предохранитель

View file

@ -1,6 +1,6 @@
[project] [project]
name = "service-tui" name = "service-tui"
version = "0.2.1" version = "0.3.0"
description = "TUI сервисного инженера для диагностики платы MIMXRT1052" description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View file

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

View file

@ -242,7 +242,13 @@ def _mock_mcuboot_ctx(monkeypatch, **method_returns):
у fill_memory/configure_memory/flash_erase_region/write_memory (все True у fill_memory/configure_memory/flash_erase_region/write_memory (все True
по умолчанию, кроме явно переопределённых). read_memory по умолчанию по умолчанию, кроме явно переопределённых). read_memory по умолчанию
возвращает «пустой чип» (Р16) существующие happy-path тесты ожидают возвращает «пустой чип» (Р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 addrbytes), а read_memory по записанному адресу
отдаёт ровно записанное (эхо), иначе `read_memory`-дефолт (blank-check
Р16 читает FLASH_BASE ДО любой записи модель пуста дефолт). Так
verify-readback happy-path совпадает без явной настройки в каждом тесте."""
defaults = dict( defaults = dict(
configure_memory=True, configure_memory=True,
flash_erase_region=True, flash_erase_region=True,
@ -254,8 +260,25 @@ def _mock_mcuboot_ctx(monkeypatch, **method_returns):
ctx = MagicMock() ctx = MagicMock()
ctx.__enter__.return_value = ctx 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)) monkeypatch.setattr(fb, "McuBoot", Mock(return_value=ctx))
return 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) ctx.reset.assert_called_once_with(reopen=False)
# Р16: два "erase"-события — проверка блочности + собственно стирание. # Р16: два "erase"-события — проверка блочности + собственно стирание.
# Фаза 5: два "verify"-события (старт + OK) между write и reset.
assert _phases(events) == [ assert _phases(events) == [
"configure", "configure",
"erase", "erase",
"erase", "erase",
"fcb", "fcb",
"write", "write",
"verify",
"verify",
"reset", "reset",
"done", "done",
] ]
@ -405,6 +431,48 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path):
assert "done" not in _phases(events) 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): def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path):
"""write_memory реально дёргает progress_callback(current, total) — см. """write_memory реально дёргает progress_callback(current, total) — см.
Фазу 0. Проверяем, что наш адаптер конвертирует это в FlashProgress.""" Фазу 0. Проверяем, что наш адаптер конвертирует это в FlashProgress."""
@ -416,6 +484,7 @@ def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path):
ctx = _mock_mcuboot_ctx(monkeypatch) ctx = _mock_mcuboot_ctx(monkeypatch)
def _fake_write_memory(address, data, mem_id=0, progress_callback=None): 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) // 2, len(data))
progress_callback(len(data), len(data)) progress_callback(len(data), len(data))
return True return True

View file

@ -1126,7 +1126,7 @@ wheels = [
[[package]] [[package]]
name = "service-tui" name = "service-tui"
version = "0.2.1" version = "0.3.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "pyinstaller" }, { name = "pyinstaller" },