# bootloader, service-tui: ready for release
This commit is contained in:
parent
6c564f1638
commit
6120e71416
37 changed files with 1884 additions and 1265 deletions
97
.github/workflows/release.yml
vendored
97
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
190
CHANGELOG.md
190
CHANGELOG.md
|
|
@ -52,19 +52,195 @@
|
||||||
|
|
||||||
### Что отслеживать
|
### Что отслеживать
|
||||||
|
|
||||||
- Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow через `just ci::build` или начинает запускать host-тесты.
|
- ~~Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow~~ — решено: job `test` уже запускает host-тесты (`just ci::test` → `just build::test-host-release`).
|
||||||
- Отслеживать появление self-hosted runner, регулярных аппаратных прогонов или отчётов по HIL.
|
- Отслеживать появление 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 — реализован, Фазы 0–6, см. запись ниже).
|
||||||
- Отслеживать локальные патчи поверх 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: полная реализация, Фазы 0–6 (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()`
|
||||||
|
(таксономия отказов A–D); recovery-режим по `BSP_BUTTON_2` с ослабленным version-gate.
|
||||||
|
- **Фаза 5 — HAB Release + service-tui.** Тестовый HAB-ключ (`tools/host/hab/keys/`, схема NOCAK) —
|
||||||
|
`hab_bootloader_release.yaml` реально подписывает Release-образ (`flags=0x08`);
|
||||||
|
`tools/service_tui/app/bootloader_client.py` — CDC-клиент bootloader
|
||||||
|
(`get_smoke_status`/`get_qspi_info`); `tools/service_tui/app/screens/verify.py` — экран живой
|
||||||
|
проверки загрузчика после серийной прошивки (Тир-1); Тир-0 (readback-верификация записи,
|
||||||
|
`flash_backend._verify_written()`) — включена по умолчанию для любой прошивки через `service-tui`;
|
||||||
|
`firmware/bootloader/SIGNING_CEREMONY.md` — план настоящей production-подписи (HAB SRK + MCUboot
|
||||||
|
production-ключ) на будущее.
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
|
||||||
|
- **Фаза 3**: детект SD консолидирован на единый `PRSSTAT`; ранний сэмпл кнопки даунгрейда.
|
||||||
|
- **Фаза 4**: `board_mpu_init()` (общий для всех прошивок код) — добавлен Region 11 под NIC-301
|
||||||
|
GPV-регистры (`0x41000000`, 8 МБ).
|
||||||
|
- **Фаза 5**: `Flasher.PRODUCTION` (`tools/service_tui/app/flasher.py`) сужен до сценария A (только
|
||||||
|
загрузчик) — бандл с `tft_app` (сценарий B) отложен до реализации `tft_app`; production жёстко
|
||||||
|
резолвит Release, независимо от переменной `FIRMWARE_BUILD_TYPE`. `just host::package-tui` —
|
||||||
|
новый явный гвард на `build/Release/bootloader_hab.bin`. `.github/workflows/release.yml` — тег
|
||||||
|
`bootloader-v*` (симметрично `firmware-v*`), общий job `firmware` теперь собирает и подписывает
|
||||||
|
Release-образ bootloader.
|
||||||
|
- Корневой `README.md` — статус bootloader `запланирован` → `реализован, v1.0.0`.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
|
||||||
|
- **Фаза 2** (3 бага, аппаратная верификация): `jump_to_image()` маскировал IRQ перед прыжком (не по
|
||||||
|
референсу NXP) — вешал `bsp_delay()` в любом целевом образе; `bsp_qspi_read()` не округлял
|
||||||
|
`IDATSZ` до кратного 4 при IP-чтении — контроллер недодавал слово на хвостах не кратной длины
|
||||||
|
(впервые проявилось на чтении хэша образа bootutil); `qspi_read_tail()` сравнивал
|
||||||
|
`IPRXFSTS.FILL` (watermark-юниты по 8 байт) напрямую со счётчиком слов — зависал на хвостах ровно
|
||||||
|
в 2 слова (чтение подписи ECDSA).
|
||||||
|
- **Фаза 3**: форсированный даунгрейд физически записывался, но не загружался бы (`boot_go()` всегда
|
||||||
|
выбирает более высокую версию) — добавлено поле `erase_previous_active`; `fih_panic_loop()`
|
||||||
|
(вендоренный bootutil) ронял `test-host-release` в CI на x86_64-раннере (`invalid instruction
|
||||||
|
mnemonic 'b'` — ARM/Thumb-only мнемоника, на arm64 devcontainer случайно ассемблировалась,
|
||||||
|
маскируя проблему); стабы `test_stub` не позиционно-независимы — линковка под конкретный слот
|
||||||
|
обязательна и для реального `tft_app`, не только для заглушки.
|
||||||
|
- **Фаза 6** (4 бага): счётчик попыток загрузки рос и на пустой плате без SD (без реального
|
||||||
|
зависания) — ошибочно уводил бы в recovery через ~4.5 с в штатном ожидании; в стенде `test_stub`
|
||||||
|
health-mark вызывался безусловно до проверки `HANG_MODE`, из-за чего счётчик никогда не
|
||||||
|
накапливался выше 1 (фолбэк не срабатывал); в самом чек-листе Фазы 6 предписывался файл для
|
||||||
|
чужого слота при проверке recovery-установки; `attempt_boot()` не инкрементировал счётчик перед
|
||||||
|
первым прыжком в свежеустановленный recovery-образ (симметрия с обычным путём).
|
||||||
|
- **Фаза 4** (3 бага): AXI-QoS регистры (NIC-301 GPV) валили C-код фолтом — не покрыты
|
||||||
|
`board_mpu_init()`, DCD успевал их записать до включения MPU, C-порт — нет; результаты
|
||||||
|
smoke-теста терялись (шлются один раз сразу после `init()`, хост не успевает открыть порт) —
|
||||||
|
кэширование + переспрос по команде; оценка длительности SDRAM-теста в комментарии оригинала
|
||||||
|
завышена ~в 6 раз (реальный прогон ~4.2 с, не ~30 с).
|
||||||
|
- **Фаза 5** (2 бага в `service-tui`, до публикации): production шил bootloader и (будущий) app по
|
||||||
|
одному адресу `FLASH_BASE` — второй шаг затёр бы первый (наследие до-bootloader архитектуры, для
|
||||||
|
Direct-XIP неверно); production мог тихо взять unsigned Debug-образ bootloader через
|
||||||
|
`FIRMWARE_BUILD_TYPE` (переменная предназначена только для firmware_test, дефолт `Debug`) — теперь
|
||||||
|
Release резолвится жёстко.
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
|
||||||
|
- Host-тесты выросли с 13 (Фаза 2) до 16 (Фазы 3/6: `update_policy`, `slot_version`, `recovery`) —
|
||||||
|
зелёные, Debug и Release, обе платформы (macOS + devcontainer Linux).
|
||||||
|
- `tools/service_tui`: 68 → 76 тестов (Фаза 5) — Тир-0 readback (`test_flash_backend.py`) + новый
|
||||||
|
`test_bootloader_client.py`.
|
||||||
|
- Полный аппаратный чек-лист пройден на каждой фазе (Фазы 2–6); детали были в удалённых
|
||||||
|
`HARDWARE_VERIFICATION_*.md`/`DEBUG_LOG_*.md` (см. git-история, "Удалено" ниже).
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
- `.github/workflows/release.yml`: новый job `publish-bootloader` (тег `bootloader-v*`), общий job
|
||||||
|
`firmware` расширен на сборку Release HAB bootloader; `service-tui-{macos,windows}` теперь
|
||||||
|
докачивают `bootloader_hab.bin` в `build/Release/` перед упаковкой — без этого `just
|
||||||
|
host::package-tui` падал бы с новым гвардом (см. "Изменено").
|
||||||
|
- CI-баг `fih_panic_loop`/x86_64 (Фаза 3, см. "Исправлено") — точечный патч вендоренного
|
||||||
|
`fault_injection_hardening.c` под `#if defined(__arm__)`.
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
|
||||||
|
- `docs/bootloader/HAB_GUIDE.md` — новый §5.1 (разбор команд CSF-секции `nxpimage`, NOCAK vs полная
|
||||||
|
SRK-иерархия).
|
||||||
|
- `firmware/bootloader/SIGNING_CEREMONY.md` — новый план настоящей production-подписи (HAB
|
||||||
|
SRK-церемония + MCUboot production-ключ), на будущее.
|
||||||
|
- `docs/DEV_ARCH.md` (корневой) — исправлена фактическая ошибка (bootloader описывался как
|
||||||
|
«копирование в ITCM», реально XIP без ITCM/DCD) и устаревший путь `tools/production/`.
|
||||||
|
- `tools/service_tui/docs/DEV_ARCH.md` → переименован в `tools/service_tui/docs/ARCHITECTURE.md`
|
||||||
|
(коллизия имени с корневым `docs/DEV_ARCH.md`); новый §16 (Тир-0/Тир-1); синхронизирован со всеми
|
||||||
|
изменениями Фазы 5; починены 10 битых/несогласованных ссылок на файл в трёх других README.
|
||||||
|
- `docs/CI_WORKFLOW.md` — синхронизирован с `bootloader-v*` (диаграмма, триггеры, таблица job'ов).
|
||||||
|
|
||||||
|
### Удалено
|
||||||
|
|
||||||
|
- `firmware/bootloader/PLAN.md`, `DEBUG_LOG_PHASE2.md`, `DEBUG_LOG_PHASE3_SD.md`,
|
||||||
|
`test_stub/HARDWARE_VERIFICATION_{PHASE2,PHASE3,PHASE6,LED_PATTERNS}.md`, `docs/CI_PLAN.md` —
|
||||||
|
планирующие/трекинговые документы и чек-листы, отработавшие своё после завершения всех фаз;
|
||||||
|
фактическое содержание либо перенесено в постоянные документы (`README.md`,
|
||||||
|
`docs/bootloader/HAB_GUIDE.md`, `docs/CI_WORKFLOW.md`), либо остаётся доступным в git-истории. Тот
|
||||||
|
же паттерн, что уже применялся к `FIRST_RELEASE_PLAN.md`/`RELEASE_ROADMAP.md`/`just/ci_workflow.md`
|
||||||
|
при предыдущем релизе (см. запись ниже).
|
||||||
|
|
||||||
|
## [2026-07-07 .. 2026-07-13] — Первый релиз: `tui-v0.2.0`/`firmware-v0.1.2`, точечный `tui-v0.2.1`
|
||||||
|
|
||||||
|
Диапазон: `8869b3c8..d9fb813b`
|
||||||
|
Сравнение: <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/раннего мержа и с тех пор
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 и аппаратной валидации на стенде.
|
|
||||||
|
|
@ -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`, «Рекомендуемые следующие шаги» (Шаги 1–2).
|
|
||||||
- **Публикация 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 пока не встроена (сама
|
||||||
|
церемония — не автоматизируемый процесс, см. документ).
|
||||||
|
|
|
||||||
|
|
@ -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 не используется — прошивка исчезает при отключении питания.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 устройств |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) — не оставлять плату/дерево
|
|
||||||
с искусственно применённым порогом.
|
|
||||||
|
|
@ -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 жив) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
# Фаза 3 (SD-путь установки) — аппаратная верификация
|
|
||||||
|
|
||||||
Памятка с готовыми командами: сборка, прошивка, подготовка SD-карты, чек-лист. Продолжение
|
|
||||||
[HARDWARE_VERIFICATION_PHASE2.md](HARDWARE_VERIFICATION_PHASE2.md) — предполагает, что прошивка
|
|
||||||
bootloader и заглушки слотов (`test_stub`) уже знакомы по Фазе 2.
|
|
||||||
|
|
||||||
Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer;
|
|
||||||
прошивка/отладка/проверки на железе — на стороне пользователя.
|
|
||||||
|
|
||||||
**Статус: ✅ все 5 сценариев §8 пройдены на реальной плате (2026-07-10, повторно — с раундом 4 и
|
|
||||||
watchdog — 2026-07-13); watchdog §9 (9.1–9.5) пройден 2026-07-13.** Историю бага card-detect и его
|
|
||||||
разрешение (чтение USDHC `PRES_STATE.CINST`) см. в [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md).
|
|
||||||
По пути найден и исправлен баг самого чек-листа (не кода) — сценарий 1 использовал не тот линкованный
|
|
||||||
стаб, см. [../PLAN.md](../PLAN.md), раздел «Найден и исправлен баг чек-листа».
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Предпосылки
|
|
||||||
|
|
||||||
- `firmware/bootloader` собран с ARM-стороной Фазы 3 (`update_policy` + `slot_version` + `sd_update` +
|
|
||||||
`bootloader_fatfs`). `main.c` перед выбором слота сканирует microSD: если вставлена и на ней лежит
|
|
||||||
подписанный `TFT_APP.BIN` — по политике версий (или по удержанию кнопки) ставит его в НЕактивный
|
|
||||||
слот, затем `boot_select_and_jump()`.
|
|
||||||
- Заглушка вместо ещё не существующего `tft_app` — та же `firmware/bootloader/test_stub/`, что и в
|
|
||||||
Фазе 2: два образа, различающиеся частотой мигания `LED_APP` (по частоте видно, какой слот реально
|
|
||||||
выбран). Оба подписаны тестовым sample-ключом MCUboot.
|
|
||||||
- **Card detect** — через регистр USDHC `PRES_STATE.CINST`, не GPIO (см. DEBUG_LOG). На пустом слоте
|
|
||||||
гейт `bsp_sd_is_inserted()` обязан быть верным, иначе блокирующий `SD_PollingCardInsert()` без
|
|
||||||
тайм-аута подвесит загрузчик; PRSSTAT это обеспечивает.
|
|
||||||
- **Обратная связь** — USB CDC ACM, тот же JSON-lines протокол, что в Фазе 1/2, плюс события
|
|
||||||
`status` (см. §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Сборка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Bootloader (Debug) + HAB-контейнер
|
|
||||||
just build::build-bootloader-debug
|
|
||||||
just build::hab-bootloader-debug
|
|
||||||
|
|
||||||
# Заглушки Slot A/Б — собрать И подписать imgtool'ом одной командой
|
|
||||||
just build::build-mcuboot-stub
|
|
||||||
```
|
|
||||||
|
|
||||||
После этого в `build/Debug/` — те же файлы, что в Фазе 2:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bootloader_hab.bin
|
|
||||||
signed/stub_a_v1_confirmed.bin (2 МБ, v1.0.0, --confirm)
|
|
||||||
signed/stub_b_v2_confirmed.bin (2 МБ, v2.0.0, --confirm)
|
|
||||||
signed/stub_a_v1_unconfirmed.bin (2 МБ, v1.0.0, без --confirm)
|
|
||||||
```
|
|
||||||
|
|
||||||
Частоты мигания `LED_APP` (по ним отличаем слот): **stub_a (v1) ≈ 1 раз/сек**, **stub_b (v2) ≈ 2
|
|
||||||
раза/сек**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Прошивка bootloader
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just host::flash-swd-bootloader-debug
|
|
||||||
# обязателен power cycle платы после прошивки — SWD-запись не ресетит автоматически
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Прошивка образов в слоты напрямую (pyOCD)
|
|
||||||
|
|
||||||
Нужно только для сценариев, где слот должен быть заполнен ДО теста (2, 3, 5) — установка с SD слоты
|
|
||||||
заполняет сама. Способ идентичен Фазе 2 (сырой подписанный `.bin` по адресу слота, не через
|
|
||||||
`flash_swd.py`).
|
|
||||||
|
|
||||||
**Важно:** `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd` — путь к `.bin`
|
|
||||||
должен быть абсолютным (`"$(pwd)/..."`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Slot A (0x60040000)
|
|
||||||
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
|
|
||||||
--base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin"
|
|
||||||
|
|
||||||
# Slot Б (0x60240000)
|
|
||||||
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
|
|
||||||
--base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_v2_confirmed.bin"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Стирание слота
|
|
||||||
|
|
||||||
Адрес — позиционный аргумент, формат диапазона `start+length`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
|
|
||||||
--sector 0x60040000+0x200000 # Slot A, 2 МБ
|
|
||||||
|
|
||||||
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
|
|
||||||
--sector 0x60240000+0x200000 # Slot Б, 2 МБ
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Подготовка SD-карты
|
|
||||||
|
|
||||||
Карта — FAT32/FAT16. Bootloader ищет в корне файл **`TFT_APP.BIN`** (путь `2:/TFT_APP.BIN`, диск `2:`
|
|
||||||
— внутренняя нумерация FatFS, физически это корень карты). Файл — подписанный образ-заглушка,
|
|
||||||
просто переименованный.
|
|
||||||
|
|
||||||
> ⚠️ **Стабы не PIC — файл на SD должен быть линкован под адрес слота, в который он реально попадёт.**
|
|
||||||
> `stub_a_*` линкован под `0x60040000` (Slot A), `stub_b_*` — под `0x60240000` (Slot Б); абсолютные
|
|
||||||
> адреса (таблица векторов, литералы) зашиты в бинарь при линковке. Установка "не того" файла в слот
|
|
||||||
> проходит crypto-гейт (подпись валидна для своего же содержимого) и доходит до прыжка, но
|
|
||||||
> исполняется код, рассчитанный на другой адрес — эффективно ничего не запускается, без ошибки на
|
|
||||||
> CDC. Тот же открытый вопрос, что и в Фазе 2 ([../PLAN.md](../PLAN.md), "разный адрес — намеренно...
|
|
||||||
> открытый вопрос из BOOTLOADER_FLASH_MAP.md §4") и уже задокументированный в проде как gap одного
|
|
||||||
> `TFT_APP.BIN` в [../../../docs/mimxrt1052/UPDATE_FLOW.md](../../../docs/mimxrt1052/UPDATE_FLOW.md).
|
|
||||||
> **Целевой слот определяет `update_policy_decide()`** (всегда НЕ активный слот; на пустой плате
|
|
||||||
> активного слота нет → по умолчанию Slot A) — файл на SD должен быть собран под этот же адрес.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сценарий 1 (чистая плата — целевой слот всегда А, т.к. активного слота нет) —
|
|
||||||
# ОБЯЗАТЕЛЬНО stub_a, не stub_b: stub_b линкован под Slot Б и не запустится в Slot A.
|
|
||||||
cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes/<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 (сценарии 1–5) как есть — все должны проходить без единого неожиданного
|
|
||||||
сброса. WDOG кормится во всех легитимных долгих операциях (стирание слота, потоковое копирование,
|
|
||||||
цикл ожидания SD) — сценарии с самой длинной легитимной работой (установка образа, сценарии 1 и 3)
|
|
||||||
— лучший регрессионный индикатор: если бы кормление где-то пропустили, именно они словили бы
|
|
||||||
ложный сброс посреди операции.
|
|
||||||
4. Оставить плату в цикле ожидания (без SD, оба слота с валидными образами не трогать) на 2+ минуты,
|
|
||||||
убедиться, что `LED_HEARTBEAT`/`LED_APP` продолжают штатный паттерн без сбросов — таймаут 10 c
|
|
||||||
означает, что пропуск хотя бы одного кормления в цикле проявился бы в пределах первой минуты.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9.5 Поведение под отладчиком (SWD halt)
|
|
||||||
|
|
||||||
Подтвердить, что `enableDebug=false` действительно приостанавливает WDOG под отладкой — иначе
|
|
||||||
пошаговая отладка любого будущего кода после `bsp_wdog_init()` была бы невозможна:
|
|
||||||
|
|
||||||
1. Запустить `🐛 Debug: bootloader` (см. Фаза 1), поставить брейкпоинт после `bsp_wdog_init()`.
|
|
||||||
2. Остановиться на нём и держать паузу **дольше 10 c** (просто подождать, не резюмировать).
|
|
||||||
3. Ожидаемо: сброса не происходит — после `Continue` выполнение продолжается с той же точки, как будто
|
|
||||||
таймер не тикал во время halt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9.6 Итоговый чек-лист
|
|
||||||
|
|
||||||
| № | Тест | Ожидаемый результат |
|
|
||||||
|---|---|---|
|
|
||||||
| 9.1 | Синтетическое зависание сразу после `bsp_wdog_init()`, без SD | Сброс ≈10 c после POR; авто-восстановление в Slot A без второго power cycle; CDC `wdog` → `recovered:true` |
|
|
||||||
| 9.2 | Синтетическое зависание в `run_update()` после `bsp_sd_init()`, карта вставлена | То же поведение, но на пути, воспроизводящем исторический класс SD/USDHC-зависаний |
|
|
||||||
| 9.3 (опц.) | Чтение `WDOG1->WRSR`/`SRC->SRSR` через pyOCD | Бит причины сброса (`TOUT`/`WDOG_RST_B`) установлен сразу после WDOG-сброса |
|
|
||||||
| 9.4 | Регрессия: чек-лист §8 без патчей, ожидание 2+ мин в цикле | Ни одного ложного сброса; `wdog` → `recovered:false` |
|
|
||||||
| 9.5 | SWD-halt дольше 10 c | Сброса нет, отладка не сбивается |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
42
tools/host/hab/keys/README.md
Normal file
42
tools/host/hab/keys/README.md
Normal 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"
|
||||||
|
```
|
||||||
234
tools/host/hab/keys/SIGNING_CEREMONY.md
Normal file
234
tools/host/hab/keys/SIGNING_CEREMONY.md
Normal 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-ключей реально фузить (1–4)?** Рекомендация: **4**, даже если реально подписывать
|
||||||
|
будет только один. Почему — см. §3.4 ниже, это дешёвая страховка на будущее, которая невозможна
|
||||||
|
постфактум (fuse пишется один раз).
|
||||||
|
- **Пароль на приватные ключи PKI-дерева** — да, аналогично MCUboot-ключу.
|
||||||
|
|
||||||
|
### 3.2 Генерация полного PKI-дерева (офлайн)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --directory tools/host nxpcrypto pki-tree hab \
|
||||||
|
-k rsa2048 -n 4 -ca -d 20 \
|
||||||
|
-o /secure/offline/path/hab_pki_2026 -p
|
||||||
|
```
|
||||||
|
|
||||||
|
`-ca` — SRK-сертификаты являются CA, под каждым генерируется своя пара CSF/IMG-сертификатов (полная
|
||||||
|
production-иерархия из `HAB_GUIDE.md` §5, а не сегодняшний NOCAK). `-n 4` — все 4 слота SRK-таблицы.
|
||||||
|
`-d 20` — срок действия сертификатов (лет); фактическая граница доверия чипа определяется fuse, не
|
||||||
|
датой сертификата, но разумный запас не помешает. Результат: CA-сертификат, 4×(SRK + CSF + IMG) —
|
||||||
|
**все приватные ключи из этого дерева — вне репозитория, аналогично MCUboot-ключу.**
|
||||||
|
|
||||||
|
### 3.3 Артефакты для `hab_bootloader_release.yaml`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SRK-таблица — то, что укажет InstallSRK_Table в новом yaml
|
||||||
|
uv run --directory tools/host nxpcrypto rot export \
|
||||||
|
-f mimxrt1050 -k SRK0_sha256_2048_65537_v3_ca_crt.pem -k SRK1_...pem -k SRK2_...pem -k SRK3_...pem \
|
||||||
|
-o tools/host/hab/keys/production/srk_table.bin
|
||||||
|
|
||||||
|
# Хэш SRK-таблицы — то, что пойдёт в fuse (§3.5)
|
||||||
|
uv run --directory tools/host nxpcrypto rot calculate-hash \
|
||||||
|
-f mimxrt1050 -k SRK0_...pem -k SRK1_...pem -k SRK2_...pem -k SRK3_...pem \
|
||||||
|
-h sha256 -o tools/host/hab/keys/production/srk_hash.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/host/hab/keys/production/` — предлагаемое место для **публичных** артефактов (SRK-таблица,
|
||||||
|
сертификаты CSF0_1/IMG0_1) — их можно коммитить, это не секрет. Приватные ключи (`*_key.pem` из
|
||||||
|
PKI-дерева) в этот путь никогда не попадают.
|
||||||
|
|
||||||
|
### 3.4 Новый `sections:` в `hab_bootloader_release.yaml`
|
||||||
|
|
||||||
|
Заменить сегодняшний `InstallNOCAK` на полную цепочку (см. разбор команд в
|
||||||
|
[HAB_GUIDE.md §5.1](../../docs/bootloader/HAB_GUIDE.md)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- Header: {...} # без изменений
|
||||||
|
- InstallSRK:
|
||||||
|
InstallSRK_Table: keys/production/srk_table.bin
|
||||||
|
InstallSRK_SourceIndex: 0 # каким из 4 слотов подписываем СЕГОДНЯ
|
||||||
|
- InstallCSFK:
|
||||||
|
InstallCSFK_File: keys/production/CSF0_1_sha256_2048_65537_v3_usr_crt.der
|
||||||
|
- AuthenticateCSF:
|
||||||
|
Signer: "type=file;file_path=/secure/offline/path/hab_pki_2026/CSF0_1_..._key.pem;password=..."
|
||||||
|
- InstallKey: # отдельный IMG-ключ — не обязателен (можно
|
||||||
|
InstallKey_File: keys/production/IMG0_1_..._crt.der # переиспользовать CSFK и для данных,
|
||||||
|
InstallKey_VerificationIndex: 0 # как сегодня NOCAK), но разделение
|
||||||
|
InstallKey_TargetIndex: 2 # CSF/IMG — стандартная рекомендация
|
||||||
|
- AuthenticateData: # AN12263 (разная ответственность ключей)
|
||||||
|
AuthenticateData_VerificationIndex: 2
|
||||||
|
Signer: "type=file;file_path=/secure/offline/path/hab_pki_2026/IMG0_1_..._key.pem;password=..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему НЕ NOCAK для production (в отличие от сегодняшнего test-ключа).** NOCAK — один
|
||||||
|
неревокируемый ключ; если он скомпрометирован, компрометация окончательна для всех уже выпущенных
|
||||||
|
чипов. SRK-таблица на 4 ключа + `SRK_REVOKE`-fuses позволяет **отозвать** активный ключ и переключиться
|
||||||
|
на другой уже зафьюженный SRK без повторной физической фуз-церемонии — вот почему в §3.1 рекомендованы
|
||||||
|
все 4 слота даже если реально подписывать будет один. День-в-день ротация CSFK/IMG-сертификата (не
|
||||||
|
SRK) вообще не требует новых fuse — только новую подпись новым сертификатом той же (уже зафьюженной)
|
||||||
|
SRK-цепочки.
|
||||||
|
|
||||||
|
### 3.5 Сборка, парсинг, аппаратная верификация — БЕЗ fuse
|
||||||
|
|
||||||
|
Ровно тот же процесс, что уже проверен в Фазе 5 с тестовым ключом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just build::hab-bootloader-release
|
||||||
|
just build::hab-verify bootloader release
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемо — `csf`-поле в IVT ненулевое (см. `PLAN.md` Фаза 5 для формата проверки, включая прямое
|
||||||
|
чтение с чипа через `pyocd commander ... read32 0x60001000 32`). Чип на этом этапе — **HAB Open**
|
||||||
|
(fuse ещё не тронуты) — тестирование безопасно и полностью обратимо: прошивать/erase/перепрошивать
|
||||||
|
сколько угодно раз, HAB Open проверяет подпись, но игнорирует ошибку (`HAB_GUIDE.md` §4.2).
|
||||||
|
|
||||||
|
**Прогнать полный чек-лист аппаратной верификации Фазы 5 заново** (тот же список из обсуждения
|
||||||
|
Фазы 5) — с этим ключом, не с тестовым — до какого-либо намерения трогать fuse.
|
||||||
|
|
||||||
|
### 3.6 ⚠️ Fuse — необратимо, только после полной уверенности
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ВАЖНО: адрес OTP-слова SRK_HASH ниже НЕ проверен в рамках этой сессии — обязательно
|
||||||
|
# свериться с MIMXRT1052 Reference Manual (глава Fusemap / OCOTP) и/или NXP AN12263 §4
|
||||||
|
# на момент исполнения, а не доверять этому файлу. Ошибка в адресе — это фуз в СОСЕДНЕЕ
|
||||||
|
# поле, тоже необратимо.
|
||||||
|
uv run --directory tools/host blhost -u <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.3–3.4 — собрать SRK-таблицу/хэш, переписать `hab_bootloader_release.yaml` (NOCAK →
|
||||||
|
SRK/CSFK/IMG).
|
||||||
|
6. [ ] §3.5 — пересобрать bootloader (уже с новым MCUboot-ключом внутри, из шага 2), подписать новым
|
||||||
|
HAB-ключом, прогнать полный чек-лист аппаратной верификации Фазы 5 на HAB Open чипе.
|
||||||
|
7. [ ] Подписать первый релиз tft_app production MCUboot-ключом (§2.4), оба слота (A/Б), проверить
|
||||||
|
end-to-end (сценарий B, отложенный в Фазе 5 до появления tft_app).
|
||||||
|
8. [ ] §3.6 — зафузить SRK_HASH на одной плате, убедиться, что HAB Open продолжает работать штатно.
|
||||||
|
9. [ ] Наблюдение/накопление уверенности (срок — отдельное решение, не техническое).
|
||||||
|
10. [ ] §3.7 — закрытие HAB (`SEC_CONFIG=1`) на производственной линии, начиная с ограниченной партии.
|
||||||
|
|
||||||
|
## 5. Что сознательно не решено в этом документе
|
||||||
|
|
||||||
|
- Точный OTP-адрес SRK_HASH для MIMXRT1052 (см. предупреждение в §3.6).
|
||||||
|
- RSA-2048 vs 4096, число реально используемых (не только зафьюженных) SRK-ключей.
|
||||||
|
- Момент закрытия HAB — отдельная веха, не блокирует первый релиз bootloader/tft_app с открытым HAB
|
||||||
|
(это и есть "Предсерийные образцы" из `HAB_GUIDE.md` §8, легитимная стадия, не временная затычка).
|
||||||
|
- Формат хранения приватных ключей (конкретный HSM/vault/парольный менеджер) — организационное
|
||||||
|
решение, не техническое.
|
||||||
21
tools/host/hab/keys/bootloader_test_hab_crt.pem
Normal file
21
tools/host/hab/keys/bootloader_test_hab_crt.pem
Normal 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-----
|
||||||
28
tools/host/hab/keys/bootloader_test_hab_key.pem
Normal file
28
tools/host/hab/keys/bootloader_test_hab_key.pem
Normal 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-----
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 или истёк таймаут."""
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
114
tools/service_tui/app/bootloader_client.py
Normal file
114
tools/service_tui/app/bootloader_client.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
241
tools/service_tui/app/screens/verify.py
Normal file
241
tools/service_tui/app/screens/verify.py
Normal 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()
|
||||||
|
|
@ -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 (текущая).** Инженерные фазы 0–5 (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`. Если в будущем
|
||||||
понадобится полный батч-режим — потребуется отдельный предохранитель
|
понадобится полный батч-режим — потребуется отдельный предохранитель
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
116
tools/service_tui/tests/test_bootloader_client.py
Normal file
116
tools/service_tui/tests/test_bootloader_client.py
Normal 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
|
||||||
|
|
@ -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 addr→bytes), а 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
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue