Merge branch 'feature-tui-monolith' into dev

# Conflicts:
#	tools/production/README.md
#	tools/production/docs/DEV_ARCH.md
This commit is contained in:
Dmitry Akimov 2026-07-07 12:25:45 +03:00
commit b4c664fe12
39 changed files with 1913 additions and 1501 deletions

View file

@ -55,86 +55,153 @@
- Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow через `just ci::build` или начинает запускать host-тесты. - Проверять, остаётся ли `.github/workflows/ci.yml` только сборочным workflow через `just ci::build` или начинает запускать host-тесты.
- Отслеживать появление self-hosted runner, регулярных аппаратных прогонов или отчётов по HIL. - Отслеживать появление self-hosted runner, регулярных аппаратных прогонов или отчётов по HIL.
- Проверять новые firmware-test модули в `firmware/test/src/tests/` и синхронные обновления протокола/документации. - Проверять новые firmware-test модули в `firmware/test/src/tests/` и синхронные обновления протокола/документации.
- Следить за развитием BSP: MQS, RGB, bootloader или `tft_app`. - Следить за развитием BSP: RGB (частично закрыто display-тестом), bootloader или `tft_app`обе директории всё ещё не заведены.
- Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log. - Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log.
- Отслеживать мерж ветки `feature-tui-monolith` в `dev` — после мержа эту запись нужно закрыть датой и финальным диапазоном SHA.
## [Не выпущено] — service-tui: кастомная прошивка нестандартной памяти (после слияния `feature-tui-python`) ## [Не выпущено] — service-tui: монолитный spsdk-бэкенд, устойчивость к обрыву USB, упаковка PyInstaller
Диапазон: `<заполнить после merge>..<текущий HEAD>` Диапазон: `1801f1beb959d610d31ee3dcd1f91046953117d4..22c40779ef0ec9911031d7a5272c4611b596d3e8` + незакоммиченные изменения рабочего дерева (документация)
Сравнение: `<заполнить после merge>` Сравнение: <https://github.com/OSabuser/tft_manufacture_test/compare/1801f1beb959d610d31ee3dcd1f91046953117d4...22c40779ef0ec9911031d7a5272c4611b596d3e8>
> Изменения внесены **поверх** слияния `feature-tui-python → dev` — базовая > Ветка `feature-tui-monolith` (от `dev`, поверх мержа `feature-tui-python`).
> архитектура `service-tui` (экраны, USB/M5-клиенты, оркестратор) приходит > **Полностью заменяет предыдущую версию этой записи**: прошивка сторонних
> самим merge-коммитом; здесь только то, что было доработано отдельно после. > бинарников через subprocess (`tools/host/flash_usb.py --fcb-path` +
> `nxpimage`) была реализацией на момент Фазы 0/раннего мержа и с тех пор
> заменена прямыми вызовами `spsdk` Python API — ничего из старой записи
> больше не описывает текущий код.
### Кратко ### Кратко
- `service-tui` теперь умеет прошивать сторонние/легаси бинарники (платы - Прошивка в `service-tui` переведена с subprocess-обёртки над
с W25Q256/512 вместо штатного W25Q128) через USB SDP, с явной записью `nxpimage`/`sdphost`/`blhost` на прямые вызовы `spsdk` Python API
FCB вместо ненадёжного для таких чипов auto-config Flashloader. (`McuBoot`/`SDP`/`HabImage`) — `app/flash_backend.py`, провалидировано
- Выбор оператора на `FlashScreen` (файл/память/DCD) запоминается на весь byte-exact на живом железе (macOS + Windows). `tools/host/flash_usb.py`
запуск TUI — ускоряет прошивку партии одинаковых плат. остаётся отдельным dev-CLI для `just host::flash*`, TUI его больше не
- Документация (`tools/production/README.md`, `tools/production/DEV_ARCH.md`, вызывает ни субпроцессом, ни как библиотеку.
`tools/host/README.md`, `docs/HOW_TO_FLASH.md`, `docs/DEV_ARCH.md`) - Обрыв USB во время прошивки/chip erase теперь надёжно типизируется во
синхронизирована с фактическим состоянием кода. всех трёх наблюдавшихся на железе сценариях (`SPSDKConnectionError`,
`SPSDKTimeoutError`, `False`-по-таймауту без исключения) и даёт оператору
единое понятное сообщение вместо «Непредвиденная ошибка».
- Собран первый standalone-бандл (PyInstaller, onedir) — alpha, вручную
протестирован на macOS и Windows.
- Документация (`tools/production/README.md`+`docs/DEV_ARCH.md`, корневые
`docs/*`, все `bsp/*/README.md`, корневой `README.md`) синхронизирована
с фактическим состоянием кода после всех фаз миграции.
### Добавлено ### Добавлено
- `tools/host/flash_usb.py``write_fcb_explicit()` + флаг `--fcb-path`: - `tools/production/app/flash_backend.py` — синхронное ядро прошивки на
буквальная запись 512-байтного FCB-блоба (`write-memory 0x60000000`) spsdk: `detect_sdp`/`detect_cdc`, `load_flashloader`, `flash`,
вместо magic option word `0xF000000F`. Штатный `--firmware`-путь `erase_chip`, `build_custom_hab` (`HabImage` вместо `nxpimage` CLI),
(`firmware_test`/`bootloader`/`app`) не тронут — работает как раньше. `write_fcb_explicit`/`write_fcb_auto`. Zero Textual/asyncio импортов,
- `tools/production/app/models.py``FcbVariant` (`W25Q128` покрывает и тестируется без event loop.
W25Q64, `W25Q512` — и W25Q256) и `FlashPreset` (липкий выбор оператора). - `tools/production/app/usb_ports.py``resolve_serial_port()` по VID:PID
- `tools/production/app/flasher.py``_build_custom_hab()`: сборка (имя порта не переносимо между перевтыкиваниями).
HAB-образа на лету через `nxpimage hab export` из «сырого» бинарника - Иерархия `FlashBackendError`/`ConnectionLostError`/`DeviceNotFoundError`/
(без FCB/IVT/DCD) в `custom_binaries/`, с опциональным `DCDFilePath`; `FlashLoaderTimeoutError`/`HabBuildError` с полем `connection_lost`
стриминг вывода `nxpimage` в UI-лог, а не только в `logger.debug`. различает физический обрыв USB от логической ошибки прошивки без
`list_custom_binaries()` + `SERVICE_CUSTOM_BINARIES_DIR` — резолв парсинга текста сообщения.
директории кастомных бинарей (внешняя, не пакуется в PyInstaller). - `tools/production/tests/test_flash_backend.py` — вырос до 45 unit-тестов
- `tools/production/app/screens/flash.py``Select` по `custom_binaries/`, backend'а, включая обе ветки обрыва USB (`SPSDKTimeoutError`,
`Select` по `FcbVariant`, `Switch` DCD вместо свободного текстового `False`-по-таймауту + вариант B через `detect_sdp()`) и golden-тест
`Input`; предзаполнение из `FlashPreset` при создании экрана. byte-exact сборки HAB.
- `tools/production/app/app.py``ServiceApp._last_flash_preset`, - Кнопка «✕ Выйти из приложения» на `WaitingScreen`.
прокидывается в новый `FlashScreen` при каждом `DeviceDetected(FLASHING)`. - `tools/production/service_tui.spec` — PyInstaller spec (onedir).
- `tools/production/docs/RELEASE_ROADMAP.md` — дорожная карта Фаз
4a→4b→5→6 с принятыми решениями (Р10Р12) и статусом гейтов.
### Изменено ### Изменено
- `tools/host/flash_usb.py`, `erase_chip()` — таймаут `blhost` - `tools/production/app/flasher.py` — переведён с subprocess
`flash-erase-all` увеличен до `-t 200000` (W25Q512 стирается заметно (`flash_usb.py` через `uv run`) на `asyncio.to_thread`-обёртку над
дольше W25Q128, дефолтного таймаута не хватало). `flash-erase-region` `flash_backend.py`; сборка кастомного HAB — через `HabImage` в отдельном
(обычная прошивка) не тронут — там стирается пара секторов, масштаб иной. потоке, а не subprocess `nxpimage`.
- `tools/production/app/app.tcss``#flash-target-group` ограничен по - `tools/production/app/main.py` — логирование: root по умолчанию `INFO`
высоте (`max-height: 18`, свой скролл), `#flash-log` защищён (было `DEBUG`), `spsdk`/`libusbsio` принудительно приглушены до
`min-height: 6` — разросшаяся custom-группа больше не сжимает лог `WARNING` независимо от root; полный DEBUG — через
прошивки до нечитаемого состояния. `SERVICE_LOG_LEVEL=DEBUG`.
- `tools/production/app/screens/flash.py` — троттлинг записи в
`#flash-log` для фазы `write` (раз на 10%, ~10 строк вместо ~135) без
потери плавности прогресс-бара.
- `bsp/sd/src/sd.c``bsp_sd_init()`/`bsp_sd_deinit()` теперь делают
аппаратный `USDHC_Reset()` + полный `memset(&g_sd, ...)` перед
повторной инициализацией: без этого non-blocking host driver SDK мог
оставаться в состоянии ожидания транзакции от предыдущей
diagnostic-сессии, и следующий `f_mount()` в тесте `usd` блокировался
навсегда.
### Исправлено
- Обёртка обрыва USB расширена с `SPSDKConnectionError` на
`(SPSDKConnectionError, SPSDKTimeoutError)` — второй тип не наследует
первый, но реально прилетает на read-фазе после write.
- Вариант B для команд, возвращающих `False` без исключения
(`flash_erase_all`/`flash_erase_region`/`write_memory`): при `False`
выполняется быстрый `detect_sdp()` — устройство пропало с шины →
`ConnectionLostError`, устройство на месте → обычная `FlashBackendError`.
- Баг «File not found» для bootloader/app/firmware_test при резолве путей
прошивки (Фаза 4a).
- Unit-тест моки (`test_cli.c`, `test_bsp_can.c`, `test_firmware_runner.c`,
stub-хедеры `fsl_clock.h`/`version.h`) — фиксы после рефакторинга
`cli.c`/`test_runner.c`.
### Тесты
- `test_flash_backend.py` — вырос до 45 тестов, включая гейт по
`SPSDKTimeoutError` и переклассификации erase-таймаута (вариант B).
### Документация ### Документация
- `tools/production/README.md` — мокап `FlashScreen` под факт (Select/Select/ - `tools/production/README.md`/`tools/production/docs/DEV_ARCH.md` —
Switch), новый workflow «Прошивка стороннего бинарника», `SERVICE_CUSTOM_BINARIES_DIR` полностью пересмотрены под факт: убраны все следы subprocess/`nxpimage`/
в примере `.env`. `flash_usb.py` из описания архитектуры прошивки; добавлены §6.2
- `tools/production/DEV_ARCH.md` — новый §8 (конвейер кастомной прошивки, (обработка обрыва USB), §14 (PyInstaller/frozen-резолв путей), §15
`FlashPreset`, явная запись FCB, обоснование отказа от auto-config для (логирование); зафиксирован разрыв между закоммиченным
W25Q256/512 и от полноценного авто-батч-режима прошивки). `service_tui.spec` (`datas` только `../shared`) и фактическим
- `docs/HOW_TO_FLASH.md` — §1.5, сноска в сравнительной таблице способов содержимым уже собранных релизных бандлов в `dist/`.
прошивки (FCB «не нужен» верно только для W25Q128). - `docs/testing/PROTOCOL.md` — версия `0.1.0`→`0.1.2`, добавлена команда
- `docs/DEV_ARCH.md``tools/production/` добавлен в дерево структуры `get_version` и события `test_list`/`uid_response`/`version_response`,
репозитория (отсутствовал ранее). матрица тестов исправлена (убраны никогда не существовавшие `uart_ttl`/
- `tools/host/README.md` — актуализирован статус `dcd/*.bin` (`w25q512_fdcb.bin` `uart_iso`, добавлен реальный `mqs`), поток Display дополнен шагами
теперь используется), указатель на `service-tui` как способ прошивки ротации (`display_rot0`/`display_rot_base`).
нестандартной памяти. - `docs/testing/host/HOST_CREATE_TEST.md` — был байт-в-байт дубликатом
`docs/HOW_TO_DEBUG.md` (копипаст-баг, минимум с 2026-06-23); переписан
как реальный гайд по добавлению host-теста.
- `docs/HOW_TO_FLASH.md` (§1.5 под факт spsdk-конвейера), `docs/DEV_ARCH.md`
(в дереве `tools/hil/` недоставало `04_test_button.py`),
`docs/testing/hil/HIL_CREATE_TEST.md` (пример `loaded_<n>` без `m5`
вводил в заблуждение — питание таргета всегда идёт через M5, не только
сигнальные реле) — актуализированы.
- `bsp/usb_cdc/README.md` (VID/PID был заявлен как заглушка `0x1234:0x0001`,
реально прошит `0x1996:0x00AD`), `bsp/uart_host/README.md` (в списке API
отсутствовали реальные `bsp_uart_host_deinit/rx_available/rx_flush`),
`bsp/can/README.md` (несуществующие в коде `bsp_can.c`/`can_mock.h`/
`bsp_can_rx_cb_t`) — исправлены по сверке с заголовками.
- `bsp/mqs/{mqs.c,mqs.h,mqs_amp.c}` — докстринги приведены в соответствие
с кодом (были «SAI1»/«16 кГц», реально SAI3/12 кГц — подтверждено
сверкой с `bsp/generated/clock_config.c`); `bsp/provisioning/provisioning.h`
— докстринг порядка байт UID исправлен на соответствующий реализации
(`provisioning.c` пишет CFG0 первым, докстринг утверждал обратное).
- Корневой `README.md``firmware/bootloader/`/`firmware/tft_app/`
помечены как запланированные, а не готовые (директорий не существует,
`add_subdirectory()` закомментирован в корневом `CMakeLists.txt`);
добавлен ранее отсутствовавший раздел «Инструменты (`tools/`)» —
`tools/production/` (service-tui) нигде не упоминался.
### Известные ограничения ### Известные ограничения
- Auto-config Flashloader для W25Q256/512 не проверялся напрямую — решение - `service_tui.spec` не включает `datas` для `spsdk`/`dcd/*.bin`/
писать FCB явно снимает вопрос архитектурно, но не подтверждает и не `pyproject.toml`, хотя уже собранные alpha-бандлы их содержат — спек
опровергает надёжность auto-config как таковую. нужно синхронизировать перед следующей сборкой релиза.
- Полноценный режим массового программирования (авто-прошивка по факту - `pyusb` в `pyproject.toml` — мёртвая зависимость (Р7 перевёл детект на
детекта USB, без подтверждения оператора) рассмотрен и отклонён — в `spsdk`/`serial.tools.list_ports`), кандидат на удаление.
SDP/Flashloader-режиме нет способа прочитать UID платы для идентификации. - Auto-config Flashloader для W25Q256/512 не проверялся напрямую — решили
- Standalone-упаковка (`PyInstaller`) для этого функционала ещё не не полагаться на него вообще, FCB для кастомных бинарей всегда пишется
реализована — см. `tools/production/RELEASE_PLAN.md`. явно.
- Массовое программирование (авто-прошивка по факту детекта SDP, без
подтверждения оператора) рассмотрено и отклонено — в SDP/Flashloader-режиме
нет способа прочитать UID платы для идентификации.
## [2026-06-29] — Этапы 6г7: MQS, HIL pytest firmware_test, Provisioning ## [2026-06-29] — Этапы 6г7: MQS, HIL pytest firmware_test, Provisioning

202
FIRST_RELEASE_PLAN.md Normal file
View file

@ -0,0 +1,202 @@
# План первого релиза в GitHub
> Цель: в GitHub Releases появляется тег с прикреплёнными бинарниками —
> `firmware_test` (HAB-образ) и `service-tui` (standalone-бандлы под macOS и
> Windows). Этот документ — практический чек-лист «как довести до кнопки
> Publish», а не повторение инженерных фаз `RELEASE_ROADMAP.md`.
## Как связаны два артефакта
`firmware_test` (C, i.MX RT1052, версия `0.1.2` из
`firmware/test/CMakeLists.txt`) и `service-tui` (Python/Textual, версия
`0.2.0` из `tools/production/pyproject.toml`) — независимо версионируемые
проекты, но релиз одного без другого бесполезен сервисному инженеру:
`service-tui` — это инструмент, которым он *прошивает* плату диагностической
прошивкой, и HAB-образ `firmware_test` кладётся внутрь бандла TUI как
`firmware/<Type>/firmware_test_hab.bin` (см. `just host::package-tui`,
`tools/production/docs/DEV_ARCH.md` §14). Поэтому релиз собирается как один
комплект, даже если версии независимые.
Известное ограничение (задокументировано в `README.md`/`DEV_ARCH.md`):
Release-сборка `firmware_test` нестабильна (проблема с FCB/clock), поэтому
в бандл TUI кладётся **Debug**-образ (`FIRMWARE_BUILD_TYPE=Debug`). В релиз
GitHub имеет смысл положить оба HAB-образа отдельными assets (Debug — как
основной для TUI, Release — с пометкой «experimental», для тех, кто прошивает
через `tools/host/flash_usb.py` вручную), либо только Debug — см. открытый
вопрос в шаге 1.5.
---
## Текущее состояние (снимок на момент написания плана)
| Область | Состояние |
| --- | --- |
| `firmware_test` | Собирается, HAB-образ генерируется (`just build::hab-firmware-test-{debug,release}`), Release нестабилен |
| `service-tui` | v0.2.0, PyInstaller onedir, alpha-бандлы уже вручную собраны и прогнаны на живом железе macOS+Windows (коммиты `c694258`/`bfe4dd6`) |
| `service_tui.spec` | **Устарел относительно того, чем реально собраны протестированные alpha-бандлы** — не содержит `datas` для `spsdk`, `dcd/*.bin`, `pyproject.toml` (задокументировано в `DEV_ARCH.md` §14 и `CHANGELOG.md`「Известные ограничения」) |
| `tools/production/dist/service-tui-v0.2.0-{macos,windows}/` | Закоммичены в git (906 файлов, ~96 МБ суммарно) и **устарели относительно HEAD** — собраны до коммитов `2dbe3e6`/`22c4077`/`31e3237` (фиксы моков тестов, рефакторинг докстрингов) |
| CI (`.github/workflows/ci.yml`) | Только `build`+`test` в devcontainer на `ubuntu-latest`; не собирает `service-tui`, нет macOS/Windows раннеров, нет release-пайплайна, нет тегов в репозитории |
| `just/ci.just` | Есть рецепт `release` (→ `just build::hab-all-release`) — только firmware, ничего про упаковку TUI или публикацию на GitHub |
| Ветки | `feature-tui-monolith` на 15 коммитов впереди `dev`, ещё не смёржена; в репозитории также есть `main` — политика, какая ветка режет релизы, явно не зафиксирована |
---
## Шаг 1 — Закрыть блокирующие долги перед тегом
Без этого CI-сборка (шаг 3) не будет соответствовать тому, что уже
провалидировано на железе, — а «релиз, который не воспроизводим из
исходников» хуже отсутствия релиза.
1. **Актуализировать `tools/production/service_tui.spec`** — добавить
`collect_data_files("spsdk")` (+ `SPSDK_DATA_FOLDER` если понадобится),
`collect_dynamic_libs("libusbsio")`, `datas` для
`tools/host/dcd/{dcd.bin,w25q128_fdcb.bin,w25q512_fdcb.bin,ivt_flashloader.bin}`
и `pyproject.toml`. Ориентир — реальное содержимое уже собранных
alpha-бандлов в `dist/` (их можно инспектировать перед удалением из git,
см. следующий пункт).
2. **Убрать `tools/production/dist/` из git**: `git rm -r --cached
tools/production/dist` + добавить `tools/production/dist/` в
`.gitignore`. Собранные бандлы — это build-артефакты, их место в GitHub
Release assets или CI-артефактах, не в истории репозитория.
3. **(Дёшево, но не блокирует)** Убрать мёртвую зависимость `pyusb` из
`tools/production/pyproject.toml` — детект давно переведён на
`spsdk`/`serial.tools.list_ports` (Р7), ни один модуль `app/` её не
импортирует.
4. **Пересобрать бандлы локально** с исправленным spec из актуального HEAD
(`just host::package-tui` на macOS и на Windows) и повторить хотя бы
дымовой прогон чек-листа Гейта 5 из `RELEASE_ROADMAP.md` (детект SDP →
прошивка `firmware_test` → диагностика → выход). Полный деструктивный
чек-лист (обрыв USB и т.п.) уже пройден на предыдущей сборке — здесь
цель убедиться, что исправленный spec не сломал состав бандла, а не
повторять всё с нуля.
5. **Открытый вопрос:** класть ли в релиз Release-сборку `firmware_test`
вообще (сейчас нестабильна) — по умолчанию план предполагает **только
Debug**-образ как основной asset, Release не публикуется до починки
FCB/clock-проблемы. Требует подтверждения.
---
## Шаг 2 — Версия и тег
1. **Схема тега**`firmware_test` (0.1.2) и `service-tui` (0.2.0)
версионируются независимо. Предлагается: тег вида `vX.Y.Z` = версия
`service-tui` (это главный продукт релиза для сервисного инженера),
версия `firmware_test` указывается в описании релиза отдельной строкой.
Альтернатива — раздельные теги (`tui-v0.2.0` + `firmware-v0.1.2`), если
в будущем оба проекта должны релизиться независимо друг от друга.
**Требует подтверждения**, план ниже считает первый вариант.
2. **Ветка релиза** — в репозитории есть и `dev`, и `main`, при этом
`main` в `git log` не встречается в истории `feature-tui-monolith`/`dev`
(нужно свериться отдельно, если `main` уже используется под что-то
другое). Рекомендация: смёржить `feature-tui-monolith → dev`, затем
`dev → main`, тег ставить на `main` — так `main` остаётся точкой,
соответствующей опубликованным релизам, а `dev` — интеграционной веткой.
**Требует подтверждения**, если у проекта другая договорённость про
`main`.
3. **`CHANGELOG.md`** — закрыть секцию `[Не выпущено] — service-tui: ...`
`[YYYY-MM-DD] — v0.2.0`, вычеркнуть из «Известные ограничения» то, что
закрывается шагом 1 (spec-расхождение, `pyusb`).
---
## Шаг 3 — CI: собрать релизные бинарники автоматически
Текущий `.github/workflows/ci.yml` собирает только `firmware_test` на
`ubuntu-latest` внутри devcontainer — этого недостаточно для
кросс-платформенной упаковки `service-tui`. Нужен отдельный workflow,
не смешанный с обычным PR-циклом (см. `just/ci_workflow.md`, «Шаг 3 —
выделить release workflow»).
Новый `.github/workflows/release.yml`, триггер — тег `v*` (плюс
`workflow_dispatch` для тестового прогона без публикации):
| Job | Раннер | Что делает |
| --- | --- | --- |
| `firmware` | `ubuntu-latest` (тот же devcontainer-подход, что в `ci.yml`) | `just ci::release``hab-all-release` (по факту нужен только `firmware_test`, Debug+Release); выгрузить `firmware_test_hab.bin` (оба типа) как артефакт |
| `service-tui-macos` | `macos-latest` | скачать firmware-артефакт из job `firmware`; `uv sync` в `tools/production`; `just host::package-tui`; заархивировать `dist/service-tui-vX.Y.Z-macos/` |
| `service-tui-windows` | `windows-latest` | то же самое, PowerShell-совместимые команды (`just`/`uv` доступны на Windows) |
| `publish-release` | `ubuntu-latest`, `needs: [firmware, service-tui-macos, service-tui-windows]` | скачать все артефакты, создать GitHub Release через `gh release create` / `softprops/action-gh-release@v2`, прикрепить `firmware_test_hab.bin` (Debug, + Release с пометкой experimental, если решение по шагу 1.5 — «класть оба»), `service-tui-vX.Y.Z-macos.zip`, `service-tui-vX.Y.Z-windows.zip` |
Важные нюансы:
- Firmware для бандла TUI собирается **один раз** в job `firmware` и
передаётся в macOS/Windows job'ы артефактом — пересобирать ARM-прошивку
на каждом раннере отдельно избыточно (и на macOS/Windows раннерах нет
подготовленного devcontainer/toolchain).
- Аппаратные гейты (детект SDP на живой плате, деструктивные сценарии
обрыва USB) **CI выполнить не может** — GitHub-hosted раннеры не видят
реальное USB-устройство. Это ручной шаг, который уже пройден один раз
вручную (коммиты «MacOS tested»/«Windows tested») и должен повторяться
вручную перед каждым релизом, пока не поднят self-hosted HIL-раннер
(см. `just/ci_workflow.md`, «Шаг 5»). План релиза это не блокирует, но
release notes должны явно фиксировать, что сборка прошла ручную проверку
на железе, а не только CI.
---
## Шаг 4 — Ручные шаги перед Publish
1. Скачать `service-tui-vX.Y.Z-{macos,windows}.zip`, собранные именно CI
(не локальную сборку из шага 1.4) — прогнать сокращённый чек-лист Гейта
5: детект SDP → прошивка `firmware_test` → диагностика на обеих ОС.
Цель — убедиться, что CI-сборка не разошлась с уже провалидированной
локальной.
2. Обновить `tools/production/README.md`/корневой `README.md` — ссылка на
релиз/инструкция «откуда скачать сервисному инженеру».
## Шаг 5 — Публикация
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
— триггерит `release.yml`. Проверить, что все 3 asset'а прикрепились и
release notes корректны (описание можно сгенерировать из секции
`CHANGELOG.md` за этот релиз + `gh release create --generate-notes` как
дополнение). Если Гейт 6 (`RELEASE_ROADMAP.md`) закрыт не полностью
(например, POST-1 сознательно отложен — это нормально, он и заявлен как
пост-релизный) — релиз всё равно можно публиковать как обычный, а не
pre-release, POST-1 не блокирует v1 по замыслу roadmap.
---
## Шаг 6 — Развитие CI после первого релиза (не блокирует, но логично заложить сразу)
Эти пункты уже зафиксированы в `just/ci_workflow.md` («Рекомендуемые
следующие шаги»), возвращаемся сюда после первого релиза:
- `lint` job (`just ci::lint` сейчас заглушка) — `clang-format --dry-run
--Werror` + `clang-tidy`.
- Coverage (`just ci::_coverage` уже есть, но не подключён в workflow).
- Публикация devcontainer image в GHCR — сократит время `firmware` job в
`release.yml` и обычном `ci.yml` (сейчас образ пересобирается в каждой
job, даже с layer-кэшем).
- Self-hosted HIL lane — единственный способ когда-нибудь автоматизировать
то, что сейчас в шаге 4 делается руками.
---
## Сводная последовательность
```
Шаг 1 (spec + dist из git + пересборка)
Шаг 2 (тег/ветка/CHANGELOG — решения по открытым вопросам)
Шаг 3 (release.yml: firmware → macOS/Windows package → publish)
Шаг 4 (ручная проверка CI-бинарников на железе)
Шаг 5 (git tag → publish)
Шаг 6 (lint/coverage/GHCR/HIL — после релиза, не блокирует)
```
## Открытые вопросы, требующие решения пользователя
| # | Вопрос | Где всплывает |
| --- | --- | --- |
| 1 | Класть ли Release-сборку `firmware_test` в релиз (сейчас нестабильна) | Шаг 1.5 |
| 2 | Схема тега — один `vX.Y.Z` (=версия TUI) или раздельные теги firmware/TUI | Шаг 2.1 |
| 3 | Тег ставится на `main` (после `dev → main`) или сразу на `dev`/на самой feature-ветке | Шаг 2.2 |

View file

@ -11,9 +11,19 @@
| Проект | Путь | Описание | | Проект | Путь | Описание |
| ------------------- | ---------------------- | -------------------------------------------------------------------------------------- | | ------------------- | ---------------------- | -------------------------------------------------------------------------------------- |
| Тестовая прошивка | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS | | Тестовая прошивка (✅ реализована) | `firmware/test/` | Входной контроль платы: CAN, UART, SDRAM, QSPI, SDIO, RGB, оптовходы, LED, кнопки, MQS |
| Загрузчик | `firmware/bootloader/` | A/B обновление через uSD. Обновляется только через USB ROM + blhost / SWD | | Загрузчик (⏳ запланирован) | `firmware/bootloader/` | A/B обновление через uSD. Обновляется только через USB ROM + blhost / SWD |
| Production прошивка | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком | | Production прошивка (⏳ запланирован) | `firmware/tft_app/` | Приложение с реализацией логики лифтового индикатора. Обновляется загрузчиком |
---
## Инструменты (`tools/`)
| Инструмент | Путь | Назначение |
| ------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------- |
| Сервисный TUI | `tools/production/` | Диагностика и прошивка готовых плат сервисным инженером (Textual, standalone-бинарь). [README](tools/production/README.md) |
| Прошивка (dev-CLI) | `tools/host/` | USB SDP / SWD прошивка при разработке (`sdphost`/`blhost`/`nxpimage`/`pyOCD`). [README](tools/host/README.md) |
| HIL-тесты | `tools/hil/` | pytest-окружение аппаратных тестов (pyOCD + M5StampPLC). [README](tools/hil/README.md) |
--- ---
@ -65,7 +75,8 @@ just host::debug-server # GDB-сервер для отладки
| NXP MCUXpresso SDK, FreeRTOS, FatFS, LittleFS | vendored | | NXP MCUXpresso SDK, FreeRTOS, FatFS, LittleFS | vendored |
| Unity, fff, SEGGER RTT | vendored | | Unity, fff, SEGGER RTT | vendored |
| pyOCD, pyserial, pytest, mpremote | `tools/hil/uv.lock` | | pyOCD, pyserial, pytest, mpremote | `tools/hil/uv.lock` |
| spsdk (nxpimage, blhost, sdphost) | `tools/host/uv.lock` | | spsdk (nxpimage, blhost, sdphost — dev-CLI) | `tools/host/uv.lock` |
| spsdk (McuBoot/SDP/HabImage — прямой Python API), Textual | `tools/production/uv.lock` |
Всё что не меняется — vendored. Сборка работает после `git clone` без интернета Всё что не меняется — vendored. Сборка работает после `git clone` без интернета
(кроме Python-зависимостей). (кроме Python-зависимостей).

View file

@ -65,7 +65,7 @@ bsp_status_t bsp_can_set_filter(uint8_t idx, uint32_t id,
uint32_t mask, bool is_extended); uint32_t mask, bool is_extended);
bsp_status_t bsp_can_accept_all(void); bsp_status_t bsp_can_accept_all(void);
bsp_status_t bsp_can_register_rx_callback(bsp_can_rx_cb_t cb, void *p_ctx); bsp_status_t bsp_can_register_rx_callback(bsp_can_rx_callback_t cb, void *p_ctx);
``` ```
**Коды возврата `bsp_can_send()`:** **Коды возврата `bsp_can_send()`:**
@ -147,7 +147,7 @@ bsp_can_set_filter(2, 0x1ABCDEF0, 0x1FFFFFFF, true);
add_host_test( add_host_test(
NAME test_bsp_can NAME test_bsp_can
SOURCES can/test_bsp_can.c SOURCES can/test_bsp_can.c
${PROJECT_SOURCE_DIR}/bsp/can/src/bsp_can.c ${PROJECT_SOURCE_DIR}/bsp/can/src/can.c
${PROJECT_SOURCE_DIR}/utils/ring_buffer/ring_buffer.c ${PROJECT_SOURCE_DIR}/utils/ring_buffer/ring_buffer.c
INCLUDES INCLUDES
${PROJECT_SOURCE_DIR}/bsp/can/include ${PROJECT_SOURCE_DIR}/bsp/can/include
@ -160,7 +160,7 @@ add_host_test(
**Humble Object** — fff-заглушки для потребителей в `bsp/can/mocks/`: **Humble Object** — fff-заглушки для потребителей в `bsp/can/mocks/`:
```c ```c
#include "can_mock.h" #include "can_mocks.h"
void setUp(void) { CAN_MOCK_RESET_ALL(); } void setUp(void) { CAN_MOCK_RESET_ALL(); }

View file

@ -161,18 +161,15 @@ static uint32_t poll_rx_mailboxes(void)
uint8_t mb_idx = RX_MB_FIRST + i; uint8_t mb_idx = RX_MB_FIRST + i;
/* Проверяем флаг готовности MB. */
uint64_t mb_flag = (uint64_t) 1U << mb_idx; uint64_t mb_flag = (uint64_t) 1U << mb_idx;
if (FLEXCAN_GetMbStatusFlags(BSP_CAN_BASE, mb_flag) == 0U) if (FLEXCAN_GetMbStatusFlags(BSP_CAN_BASE, mb_flag) == 0U)
{ {
continue; continue;
} }
/* Читаем фрейм из MB. */
flexcan_frame_t sdk_frame; flexcan_frame_t sdk_frame;
status_t sdk_status = FLEXCAN_ReadRxMb(BSP_CAN_BASE, mb_idx, &sdk_frame); status_t sdk_status = FLEXCAN_ReadRxMb(BSP_CAN_BASE, mb_idx, &sdk_frame);
/* Очищаем флаг. */
FLEXCAN_ClearMbStatusFlags(BSP_CAN_BASE, mb_flag); FLEXCAN_ClearMbStatusFlags(BSP_CAN_BASE, mb_flag);
if ((sdk_status == kStatus_Success) || (sdk_status == kStatus_FLEXCAN_RxOverflow)) if ((sdk_status == kStatus_Success) || (sdk_status == kStatus_FLEXCAN_RxOverflow))
@ -180,7 +177,6 @@ static uint32_t poll_rx_mailboxes(void)
bsp_can_frame_t bsp_frame; bsp_can_frame_t bsp_frame;
frame_from_sdk(&sdk_frame, &bsp_frame); frame_from_sdk(&sdk_frame, &bsp_frame);
/* Сериализуем фрейм побайтово в ring buffer. */
ring_buffer_write(&g_s_rx_ring, (const uint8_t *) &bsp_frame, sizeof(bsp_frame)); ring_buffer_write(&g_s_rx_ring, (const uint8_t *) &bsp_frame, sizeof(bsp_frame));
received++; received++;
} }
@ -222,16 +218,13 @@ bsp_status_t bsp_can_init(const bsp_can_config_t *p_config)
return BSP_ERR_PARAM; return BSP_ERR_PARAM;
} }
/* Если уже инициализирован — сначала деинициализируем. */
if (g_s_initialized) if (g_s_initialized)
{ {
bsp_can_deinit(); bsp_can_deinit();
} }
/* Инициализация ring buffer. */
ring_buffer_init(&g_s_rx_ring, g_s_rx_ring_storage, RX_RING_SIZE); ring_buffer_init(&g_s_rx_ring, g_s_rx_ring_storage, RX_RING_SIZE);
/* Конфигурация FlexCAN. */
flexcan_config_t flexcan_cfg; flexcan_config_t flexcan_cfg;
FLEXCAN_GetDefaultConfig(&flexcan_cfg); FLEXCAN_GetDefaultConfig(&flexcan_cfg);
@ -306,7 +299,6 @@ bsp_status_t bsp_can_set_filter(uint8_t index, uint32_t can_id, uint32_t mask, b
uint8_t mb_idx = RX_MB_FIRST + index; uint8_t mb_idx = RX_MB_FIRST + index;
/* Конфигурация RX MB. */
flexcan_rx_mb_config_t rx_mb_cfg; flexcan_rx_mb_config_t rx_mb_cfg;
rx_mb_cfg.type = kFLEXCAN_FrameTypeData; rx_mb_cfg.type = kFLEXCAN_FrameTypeData;
@ -422,14 +414,12 @@ bsp_status_t bsp_can_send(const bsp_can_frame_t *p_frame, uint32_t timeout_ms)
flexcan_frame_t sdk_frame; flexcan_frame_t sdk_frame;
frame_to_sdk(p_frame, &sdk_frame); frame_to_sdk(p_frame, &sdk_frame);
/* Записать фрейм в TX MB. */
status_t wr_status = FLEXCAN_WriteTxMb(BSP_CAN_BASE, TX_MB_IDX, &sdk_frame); status_t wr_status = FLEXCAN_WriteTxMb(BSP_CAN_BASE, TX_MB_IDX, &sdk_frame);
if (wr_status != kStatus_Success) if (wr_status != kStatus_Success)
{ {
return BSP_ERR_BUSY; return BSP_ERR_BUSY;
} }
/* Ждать завершения передачи с таймаутом. */
uint64_t tx_flag = (uint64_t) 1U << TX_MB_IDX; uint64_t tx_flag = (uint64_t) 1U << TX_MB_IDX;
uint32_t start_ms = bsp_tick_get_ms(); uint32_t start_ms = bsp_tick_get_ms();
@ -442,7 +432,6 @@ bsp_status_t bsp_can_send(const bsp_can_frame_t *p_frame, uint32_t timeout_ms)
} }
} }
/* Очистить флаг завершения. */
FLEXCAN_ClearMbStatusFlags(BSP_CAN_BASE, tx_flag); FLEXCAN_ClearMbStatusFlags(BSP_CAN_BASE, tx_flag);
return BSP_OK; return BSP_OK;
@ -471,16 +460,13 @@ bsp_status_t bsp_can_receive(bsp_can_frame_t *p_frame, uint32_t timeout_ms)
for (;;) for (;;)
{ {
/* Опросить все активные MB, сложить в ring buffer. */
poll_rx_mailboxes(); poll_rx_mailboxes();
/* Попробовать извлечь фрейм. */
if (try_dequeue_frame(p_frame)) if (try_dequeue_frame(p_frame))
{ {
return BSP_OK; return BSP_OK;
} }
/* Проверить таймаут. */
uint32_t elapsed = bsp_tick_get_ms() - start_ms; uint32_t elapsed = bsp_tick_get_ms() - start_ms;
if (elapsed >= timeout_ms) if (elapsed >= timeout_ms)
{ {

View file

@ -1,10 +1,10 @@
/** /**
* @file bsp/mqs.h * @file bsp/mqs.h
* @brief BSP: Medium Quality Sound (MQS) SAI1 + eDMA + MQS. * @brief BSP: Medium Quality Sound (MQS) SAI3 + eDMA + MQS.
* *
* Слой абстракции над SAI1/eDMA/MQS для монофонического аудио-выхода. * Слой абстракции над SAI3/eDMA/MQS для монофонического аудио-выхода.
* Физически на плате выведен один канал (MQS_RIGHT, GPIO_AD_B0_04); * Физически на плате выведен один канал (MQS_RIGHT, GPIO_AD_B0_04);
* SAI1 требует стерео-буфер оба канала всегда идентичны. * SAI3 требует стерео-буфер оба канала всегда идентичны.
* *
* Режимы использования: * Режимы использования:
* - firmware_test: bsp_mqs_play_blocking() синхронная подача * - firmware_test: bsp_mqs_play_blocking() синхронная подача
@ -38,14 +38,14 @@ extern "C"
* Параметры аудио-потока * Параметры аудио-потока
* ----------------------------------------------------------------------- */ * ----------------------------------------------------------------------- */
/** Частота дискретизации, Гц. Небольшое отклонение (~0.5%) из-за /** Частота дискретизации, Гц. Источник — SAI3_CLK_ROOT (Audio PLL / 8 / 8),
* источника SAI1_CLK_ROOT (System PLL PFD2, не Audio PLL). */ * делитель MCLK подобран точно (8), отклонения нет. */
#define BSP_MQS_SAMPLE_RATE_HZ (44100U) #define BSP_MQS_SAMPLE_RATE_HZ (44100U)
/** Разрядность PCM. MQS поддерживает только 16 бит. */ /** Разрядность PCM. MQS поддерживает только 16 бит. */
#define BSP_MQS_BIT_WIDTH (16U) #define BSP_MQS_BIT_WIDTH (16U)
/** Количество каналов в буфере. SAI1+MQS требует стерео; правый == левый. */ /** Количество каналов в буфере. SAI3+MQS требует стерео; правый == левый. */
#define BSP_MQS_CHANNELS (2U) #define BSP_MQS_CHANNELS (2U)
/** Байт на один моно-сэмпл (16 бит → 2 байта). */ /** Байт на один моно-сэмпл (16 бит → 2 байта). */
@ -73,11 +73,11 @@ extern "C"
* ----------------------------------------------------------------------- */ * ----------------------------------------------------------------------- */
/** /**
* @brief Инициализация MQS-подсистемы: SAI1, eDMA, DMAMUX, MQS. * @brief Инициализация MQS-подсистемы: SAI3, eDMA, DMAMUX, MQS.
* *
* Включает тактирование SAI1 (kCLOCK_Sai1), настраивает SAI1 в режиме * Включает тактирование SAI3 (kCLOCK_Sai3), настраивает SAI3 в режиме
* TX Master, 16 бит, стерео, 44100 Гц, инициализирует eDMA канал 0 * TX Master, 16 бит, стерео, 44100 Гц, инициализирует eDMA канал 0
* (DMAMUX source kDmaRequestMuxSai1Tx) и MQS-модуль. * (DMAMUX source kDmaRequestMuxSai3Tx) и MQS-модуль.
* *
* Пин GPIO_AD_B0_04 (MQS_RIGHT) уже замультиплексирован в BOARD_InitPins(). * Пин GPIO_AD_B0_04 (MQS_RIGHT) уже замультиплексирован в BOARD_InitPins().
* MQS oversample (×32) уже выставлен в BOARD_BootClockRUN(). * MQS oversample (×32) уже выставлен в BOARD_BootClockRUN().
@ -88,7 +88,7 @@ extern "C"
bsp_status_t bsp_mqs_init(void); bsp_status_t bsp_mqs_init(void);
/** /**
* @brief Деинициализация: остановить DMA, сбросить SAI1 и MQS. * @brief Деинициализация: остановить DMA, сбросить SAI3 и MQS.
* *
* Безопасно вызывать даже если воспроизведение уже завершилось. * Безопасно вызывать даже если воспроизведение уже завершилось.
* После вызова модуль требует повторного bsp_mqs_init(). * После вызова модуль требует повторного bsp_mqs_init().
@ -152,7 +152,7 @@ extern "C"
* ----------------------------------------------------------------------- */ * ----------------------------------------------------------------------- */
/** /**
* @brief Инициализация усилителя: PWM4 SM0, 16 кГц, duty 50%. * @brief Инициализация усилителя: PWM4 SM0, 12 кГц, duty 50%.
* *
* Настраивает XBARA1 (fault disable), PWM4 submodule 0 channel A. * Настраивает XBARA1 (fault disable), PWM4 submodule 0 channel A.
* Вызывать до bsp_mqs_play() без ШИМ на VOLUME усиление равно нулю. * Вызывать до bsp_mqs_play() без ШИМ на VOLUME усиление равно нулю.

View file

@ -1,19 +1,21 @@
/** /**
* @file bsp_mqs.c * @file bsp_mqs.c
* @brief BSP MQS: SAI1 TX + eDMA + MQS для MIMXRT1052CVJ5B. * @brief BSP MQS: SAI3 TX + eDMA + MQS для MIMXRT1052CVJ5B.
* *
* Тактирование: * Тактирование:
* SAI1_CLK_ROOT = SysPLL × (18/27) / (SAI1_CLK_PRED+1=4) / (SAI1_CLK_PODF+1=2) * Audio PLL = 24 МГц × (30 + 66/625) = 722.534 МГц
* 63 529 411 Гц (BOARD_BOOTCLOCKRUN_SAI1_CLK_ROOT) * SAI3_CLK_ROOT = Audio PLL / 8 / 8 = 11 289 600 Гц
* (kCLOCK_Sai3Mux=2, Sai3PreDiv=7, Sai3Div=7,
* BOARD_BOOTCLOCKRUN_SAI3_CLK_ROOT)
* Bit clock = 44100 × 16 × 2 = 1 411 200 Гц * Bit clock = 44100 × 16 × 2 = 1 411 200 Гц
* MCLK делитель = 63 529 411 / 1 411 200 45.0 (погрешность ~0.5 %) * MCLK делитель = 11 289 600 / 1 411 200 = 8 (точно, без погрешности)
* *
* MQS oversample = 32, уже выставлен в BOARD_BootClockRUN() через * MQS oversample = 32, уже выставлен в BOARD_BootClockRUN() через
* IOMUXC_MQSConfig(IOMUXC_GPR, kIOMUXC_MqsPwmOverSampleRate32, 0). * IOMUXC_MQSConfig(IOMUXC_GPR, kIOMUXC_MqsPwmOverSampleRate32, 0).
* *
* Пин: GPIO_AD_B0_04 MQS_RIGHT замультиплексирован в BOARD_InitPins(). * Пин: GPIO_AD_B0_04 MQS_RIGHT замультиплексирован в BOARD_InitPins().
* *
* eDMA: DMA0 канал 0, DMAMUX source kDmaRequestMuxSai1Tx. * eDMA: DMA0 канал 0, DMAMUX source kDmaRequestMuxSai3Tx.
* Канал 0 зарезервирован за bsp_mqs. Прочие модули каналы 1+. * Канал 0 зарезервирован за bsp_mqs. Прочие модули каналы 1+.
* *
* SAI API (SDK 2.4.7 / fsl_sai.h, fsl_sai_edma.h 2.7.3): * SAI API (SDK 2.4.7 / fsl_sai.h, fsl_sai_edma.h 2.7.3):
@ -47,17 +49,17 @@
#define MQS_SAI_CLOCK_GATE kCLOCK_Sai3 #define MQS_SAI_CLOCK_GATE kCLOCK_Sai3
#define MQS_SAI_CLK_FREQ BOARD_BOOTCLOCKRUN_SAI3_CLK_ROOT #define MQS_SAI_CLK_FREQ BOARD_BOOTCLOCKRUN_SAI3_CLK_ROOT
/** eDMA канал, выделенный под SAI1 TX. */ /** eDMA канал, выделенный под SAI3 TX. */
#define MQS_DMA_CHANNEL (0U) #define MQS_DMA_CHANNEL (0U)
/** DMAMUX запрос для SAI1 TX. */ /** DMAMUX запрос для SAI3 TX. */
#define MQS_DMAMUX_SOURCE kDmaRequestMuxSai3Tx #define MQS_DMAMUX_SOURCE kDmaRequestMuxSai3Tx
/** Приоритет прерывания DMA (ниже USB = 3, выше нормальных задач). */ /** Приоритет прерывания DMA (ниже USB = 3, выше нормальных задач). */
#define MQS_DMA_IRQ_PRIORITY (5U) #define MQS_DMA_IRQ_PRIORITY (5U)
#define MQS_HMCLK_GATE kCLOCK_Mqs #define MQS_HMCLK_GATE kCLOCK_Mqs
/** /**
* FIFO watermark половина глубины FIFO SAI1. * FIFO watermark половина глубины FIFO SAI3.
* FSL_FEATURE_SAI_FIFO_COUNTn(x) принимает экземпляр SAI и возвращает * FSL_FEATURE_SAI_FIFO_COUNTn(x) принимает экземпляр SAI и возвращает
* глубину FIFO в словах (32 для RT1052). Деление на 2 даёт оптимальную * глубину FIFO в словах (32 для RT1052). Деление на 2 даёт оптимальную
* латентность DMA: запрос формируется когда в FIFO остаётся место для * латентность DMA: запрос формируется когда в FIFO остаётся место для
@ -108,11 +110,6 @@ static void mqs_edma_callback(I2S_Type *p_base, sai_edma_handle_t *p_handle, sta
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
* Публичный API * Публичный API
* ----------------------------------------------------------------------- */ * ----------------------------------------------------------------------- */
/*
* AUDIO PLL setting: Frequency = Fref * (DIV_SELECT + NUM / DENOM)
* = 24 * (32 + 768/1000)
* = 786.432 MHz
*/
bsp_status_t bsp_mqs_init(void) bsp_status_t bsp_mqs_init(void)
{ {
@ -121,7 +118,7 @@ bsp_status_t bsp_mqs_init(void)
return BSP_OK; return BSP_OK;
} }
/* --- Тактирование SAI1 --- */ /* --- Тактирование SAI3 --- */
CLOCK_EnableClock(MQS_SAI_CLOCK_GATE); CLOCK_EnableClock(MQS_SAI_CLOCK_GATE);
/* --- Тактирование MQS (CCGR0[CG2]) --- */ /* --- Тактирование MQS (CCGR0[CG2]) --- */
@ -132,10 +129,10 @@ bsp_status_t bsp_mqs_init(void)
IOMUXC_MQSEnterSoftwareReset(IOMUXC_GPR, false); IOMUXC_MQSEnterSoftwareReset(IOMUXC_GPR, false);
IOMUXC_MQSEnable(IOMUXC_GPR, true); IOMUXC_MQSEnable(IOMUXC_GPR, true);
/* --- SAI1: базовая инициализация (снимает reset, включает clock gate) --- */ /* --- SAI3: базовая инициализация (снимает reset, включает clock gate) --- */
SAI_Init(MQS_SAI_BASE); SAI_Init(MQS_SAI_BASE);
/* --- SAI1 TX: классический I2S, master, 16 бит, стерео, канал 0. --- */ /* --- SAI3 TX: классический I2S, master, 16 бит, стерео, канал 0. --- */
sai_transceiver_t sai_cfg; sai_transceiver_t sai_cfg;
SAI_GetLeftJustifiedConfig(&sai_cfg, kSAI_WordWidth16bits, kSAI_Stereo, SAI_GetLeftJustifiedConfig(&sai_cfg, kSAI_WordWidth16bits, kSAI_Stereo,
@ -159,7 +156,7 @@ bsp_status_t bsp_mqs_init(void)
EDMA_Init(DMA0, &dma_cfg); EDMA_Init(DMA0, &dma_cfg);
EDMA_CreateHandle(&s_dma_handle, DMA0, MQS_DMA_CHANNEL); EDMA_CreateHandle(&s_dma_handle, DMA0, MQS_DMA_CHANNEL);
/* --- DMAMUX: канал 0 → SAI1 TX --- */ /* --- DMAMUX: канал 0 → SAI3 TX --- */
DMAMUX_Init(DMAMUX); DMAMUX_Init(DMAMUX);
DMAMUX_SetSource(DMAMUX, MQS_DMA_CHANNEL, (uint8_t) MQS_DMAMUX_SOURCE); DMAMUX_SetSource(DMAMUX, MQS_DMA_CHANNEL, (uint8_t) MQS_DMAMUX_SOURCE);
DMAMUX_EnableChannel(DMAMUX, MQS_DMA_CHANNEL); DMAMUX_EnableChannel(DMAMUX, MQS_DMA_CHANNEL);
@ -188,7 +185,7 @@ void bsp_mqs_deinit(void)
} }
SAI_TransferTerminateSendEDMA(MQS_SAI_BASE, &s_sai_tx_handle); SAI_TransferTerminateSendEDMA(MQS_SAI_BASE, &s_sai_tx_handle);
/* SAI_TxReset() — сброс logic + FIFO (аналог kSAI_ResetAll). /* SAI_TxReset() — сброс logic + FIFO (аналог kSAI_ResetAll) для SAI3.
* SAI_TxSoftwareReset() отсутствует в данной версии SDK. */ * SAI_TxSoftwareReset() отсутствует в данной версии SDK. */
SAI_TxReset(MQS_SAI_BASE); SAI_TxReset(MQS_SAI_BASE);
IOMUXC_MQSEnable(IOMUXC_GPR, false); IOMUXC_MQSEnable(IOMUXC_GPR, false);

View file

@ -9,7 +9,7 @@
* аудио-сигнала на входе (MQS_RIGHT через RC-фильтр SOUND_OUT). * аудио-сигнала на входе (MQS_RIGHT через RC-фильтр SOUND_OUT).
* *
* Управление громкостью: * Управление громкостью:
* PWM4 SM0 PWM_A, частота 16 кГц, центрально-симметричный режим. * PWM4 SM0 PWM_A, частота 12 кГц, центрально-симметричный режим.
* duty 0% DC_VOL 0 В усиление минимально (тишина). * duty 0% DC_VOL 0 В усиление минимально (тишина).
* duty 50% DC_VOL 2.5 В номинальная громкость. * duty 50% DC_VOL 2.5 В номинальная громкость.
* duty 100% DC_VOL 5 В максимальное усиление. * duty 100% DC_VOL 5 В максимальное усиление.
@ -22,7 +22,7 @@
* Тактирование: * Тактирование:
* IPG clock = AHB/4 = 600/4 = 150 МГц. * IPG clock = AHB/4 = 600/4 = 150 МГц.
* PWM prescaler = /16 PWM clock = 9.375 МГц. * PWM prescaler = /16 PWM clock = 9.375 МГц.
* Fpwm = 16000 Гц (центрально-симметричный режим). * Fpwm = 9 375 000 / 586 / 2 = 12000 Гц (центрально-симметричный режим).
*/ */
#include "bsp/mqs.h" #include "bsp/mqs.h"
@ -96,7 +96,7 @@ bsp_status_t bsp_mqs_amp_init(void)
/* --- ForceSignal: использовать нормальный PWM-сигнал --- */ /* --- ForceSignal: использовать нормальный PWM-сигнал --- */
PWM_SetupForceSignal(AMP_PWM_BASE, AMP_PWM_SUBMODULE, AMP_PWM_CHANNEL, kPWM_UsePwm); PWM_SetupForceSignal(AMP_PWM_BASE, AMP_PWM_SUBMODULE, AMP_PWM_CHANNEL, kPWM_UsePwm);
/* --- PWM-сигнал: 16 кГц, центрально-симметричный, duty 50% --- */ /* --- PWM-сигнал: 12 кГц, центрально-симметричный, duty 50% --- */
const pwm_signal_param_t PWM_SIGNAL = { const pwm_signal_param_t PWM_SIGNAL = {
.pwmChannel = AMP_PWM_CHANNEL, .pwmChannel = AMP_PWM_CHANNEL,
.dutyCyclePercent = AMP_DEFAULT_DUTY, .dutyCyclePercent = AMP_DEFAULT_DUTY,

View file

@ -12,7 +12,6 @@
* Пин LOW (тока нет) BSP_OPTO_STATE_INACTIVE * Пин LOW (тока нет) BSP_OPTO_STATE_INACTIVE
* *
* Режимы каналов (bsp_opto_ch_mode_t): * Режимы каналов (bsp_opto_ch_mode_t):
* ы
* BSP_OPTO_MODE_LEVEL IN1, IN2 * BSP_OPTO_MODE_LEVEL IN1, IN2
* Детектирование уровня с программным дебаунсом. * Детектирование уровня с программным дебаунсом.
* ISR переключает направление прерывания (RISINGFALLING) после каждого фронта, * ISR переключает направление прерывания (RISINGFALLING) после каждого фронта,

View file

@ -25,9 +25,8 @@
* @brief Прочитать уникальный идентификатор чипа из OCOTP. * @brief Прочитать уникальный идентификатор чипа из OCOTP.
* *
* Читает OCOTP_CFG0 (UID[31:0]) и OCOTP_CFG1 (UID[63:32]). * Читает OCOTP_CFG0 (UID[31:0]) и OCOTP_CFG1 (UID[63:32]).
* Результат записывается в big-endian порядке: p_uid[0] старший байт CFG1, * Результат записывается в нативном порядке байт (little-endian на Cortex-M7):
* p_uid[7] младший байт CFG0. Hex-строка совпадает с визуальным порядком слов * p_uid[0..3] = CFG0 (UID[31:0]), p_uid[4..7] = CFG1 (UID[63:32]).
* в Reference Manual (MIMXRT1052RM Table 46-2).
* *
* Функция выполняет OCOTP_Init() и включает clock gate перед чтением. * Функция выполняет OCOTP_Init() и включает clock gate перед чтением.
* Clock gate остаётся открытым после вызова (паттерн проекта). * Clock gate остаётся открытым после вызова (паттерн проекта).

View file

@ -44,12 +44,16 @@ flowchart TD
```c ```c
bsp_status_t bsp_uart_host_init(uint32_t baud); bsp_status_t bsp_uart_host_init(uint32_t baud);
void bsp_uart_host_deinit(void);
bsp_status_t bsp_uart_host_write(const uint8_t *p_data, size_t len); bsp_status_t bsp_uart_host_write(const uint8_t *p_data, size_t len);
bsp_status_t bsp_uart_host_write_str(const char *p_str); bsp_status_t bsp_uart_host_write_str(const char *p_str);
size_t bsp_uart_host_read(uint8_t *p_buf, size_t len, uint32_t timeout_ms); size_t bsp_uart_host_read(uint8_t *p_buf, size_t len, uint32_t timeout_ms);
int32_t bsp_uart_host_read_byte(uint32_t timeout_ms); int32_t bsp_uart_host_read_byte(uint32_t timeout_ms);
size_t bsp_uart_host_rx_available(void); /* байт в RX-буфере прямо сейчас */
void bsp_uart_host_rx_flush(void); /* сбросить содержимое RX-буфера */
``` ```
`bsp_uart_host_read()` возвращает фактически прочитанное количество байт — `bsp_uart_host_read()` возвращает фактически прочитанное количество байт —

View file

@ -26,7 +26,7 @@
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Константы */ /* Константы */
/* -------------------------------------------------s------------------------- */ /* -------------------------------------------------------------------------- */
/** Передать в timeout_ms чтобы ждать данные бесконечно. */ /** Передать в timeout_ms чтобы ждать данные бесконечно. */
#define BSP_UART_HOST_WAIT_FOREVER (UINT32_MAX) #define BSP_UART_HOST_WAIT_FOREVER (UINT32_MAX)

View file

@ -84,17 +84,14 @@ bsp_status_t bsp_uart_host_init(uint32_t baud_rate)
return BSP_ERR_INIT; return BSP_ERR_INIT;
} }
/* Инициализация кольцевого буфера. */
if (!ring_buffer_init(&g_s_rx_ring, g_s_rx_buf, BSP_UART_HOST_RX_BUFFER_SIZE)) if (!ring_buffer_init(&g_s_rx_ring, g_s_rx_buf, BSP_UART_HOST_RX_BUFFER_SIZE))
{ {
/* Размер не степень двойки — ошибка конфигурации. */ /* Размер не степень двойки — ошибка конфигурации. */
return BSP_ERR_INIT; return BSP_ERR_INIT;
} }
/* Тактирование LPUART1. */
CLOCK_EnableClock(kCLOCK_Lpuart1); CLOCK_EnableClock(kCLOCK_Lpuart1);
/* Настройка периферии. */
lpuart_config_t config; lpuart_config_t config;
LPUART_GetDefaultConfig(&config); LPUART_GetDefaultConfig(&config);
config.baudRate_Bps = baud_rate; config.baudRate_Bps = baud_rate;
@ -191,7 +188,6 @@ size_t bsp_uart_host_read(uint8_t *p_buf, size_t len, uint32_t timeout_ms)
continue; continue;
} }
/* Буфер пуст — проверяем таймаут. */
if (timeout_ms == 0U) if (timeout_ms == 0U)
{ {
break; break;

View file

@ -19,7 +19,9 @@ COM-порт (`/dev/ttyACM*` на Linux/macOS, `COMx` на Windows). Испол
Скорость: High-Speed (480 Mbit/s), fallback Full-Speed (12 Mbit/s). Скорость: High-Speed (480 Mbit/s), fallback Full-Speed (12 Mbit/s).
PHY калибровка: `D_CAL=0x0C`, `TXCAL45DP=0x06`, `TXCAL45DM=0x06`. PHY калибровка: `D_CAL=0x0C`, `TXCAL45DP=0x06`, `TXCAL45DM=0x06`.
**VID/PID**: `0x1234` / `0x0001` — placeholder, заменить на производственные. **VID/PID**: `0x1996` / `0x00AD` (`usb_device_descriptor.h`) — тот же
идентификатор, что `tools/production/` (service-tui) использует для
детекта CDC-порта firmware_test (`SERVICE_CDC_VID`/`SERVICE_CDC_PID`).
--- ---

View file

@ -36,7 +36,7 @@ extern "C"
* *
* @pre board_hw_init() вызван (MPU настроен, NonCacheable регион активен). * @pre board_hw_init() вызван (MPU настроен, NonCacheable регион активен).
* *
* @return BSP_OK при успехе, BSP_ERR_HW при ошибке инициализациистека. * @return BSP_OK при успехе, BSP_ERR_HW при ошибке инициализации стека.
*/ */
bsp_status_t bsp_usb_cdc_init(void); bsp_status_t bsp_usb_cdc_init(void);

View file

@ -566,8 +566,6 @@ bsp_status_t bsp_usb_cdc_init(void)
USB_DeviceIsrEnable(); USB_DeviceIsrEnable();
/* FIXME:Задержка для стабилизации DP pull-down. */
//SDK_DelayAtLeastUs(USB_ATTACH_DELAY_US, SDK_DEVICE_MAXIMUM_CPU_CLOCK_FREQUENCY);
bsp_delay(USB_ATTACH_DELAY_US / 1000); bsp_delay(USB_ATTACH_DELAY_US / 1000);
USB_DeviceRun(g_usbDeviceHandle); USB_DeviceRun(g_usbDeviceHandle);

View file

@ -210,6 +210,7 @@ flowchart LR
│ ├── 01_test_uart.py ← HIL тест bsp_uart_host (без M5) │ ├── 01_test_uart.py ← HIL тест bsp_uart_host (без M5)
│ ├── 02_test_opto.py ← HIL тест bsp_opto (через M5StampPLC) │ ├── 02_test_opto.py ← HIL тест bsp_opto (через M5StampPLC)
│ ├── 03_test_can.py ← HIL тест bsp_can │ ├── 03_test_can.py ← HIL тест bsp_can
│ ├── 04_test_button.py ← HIL тест bsp_button (интерактивный, оператор)
│ ├── 05_test_usb_cdc.py ← HIL тест USB CDC (bsp_usb_cdc, UART CLI) │ ├── 05_test_usb_cdc.py ← HIL тест USB CDC (bsp_usb_cdc, UART CLI)
│ ├── 06_test_firmware_opto.py ← HIL тест opto через firmware_test CDC │ ├── 06_test_firmware_opto.py ← HIL тест opto через firmware_test CDC
│ ├── 06_test_firmware_can.py ← HIL тест CAN через firmware_test CDC │ ├── 06_test_firmware_can.py ← HIL тест CAN через firmware_test CDC

View file

@ -81,16 +81,21 @@ auto-config не подтверждена — см. 1.5.
`service-tui` (`tools/production/`) умеет прошивать бинарники, собранные не `service-tui` (`tools/production/`) умеет прошивать бинарники, собранные не
в этом репозитории (например, старые платы с W25Q512), тем же способом в этом репозитории (например, старые платы с W25Q512), тем же способом
(USB SDP), но с двумя отличиями от штатного пути: (USB SDP), но с двумя отличиями от штатного пути. Это **отдельная
реализация**, не связанная с `flash_usb.py`/`nxpimage` CLI — TUI прошивает
in-process через Python API `spsdk` (`app/flash_backend.py`: `HabImage`,
`McuBoot`, `SDP`), без единого subprocess:
- HAB-образ (IVT + опционально DCD) собирается из **сырого** бинарника на - HAB-образ (IVT + опционально DCD) собирается из **сырого** бинарника на
лету через `nxpimage`, а не заранее через `just build::hab-*` лету через `HabImage` (spsdk), а не заранее через `just build::hab-*`
- FCB пишется **явно** (`flash_usb.py --fcb-path tools/host/dcd/w25qXXX_fdcb.bin`, - FCB пишется **явно** (`mboot.write_memory()` с готовым блобом
буквальный `write-memory` вместо `configure-memory 0xF000000F`) — auto-config `tools/host/dcd/w25qXXX_fdcb.bin`, буквальная запись вместо
для 4-байтной адресации не проверялся, решили на него не полагаться `configure-memory 0xF000000F`) — auto-config для 4-байтной адресации не
проверялся, решили на него не полагаться
Подробности конвейера — в `tools/production/DEV_ARCH.md`, §8. Штатный путь Подробности конвейера — в [tools/production/docs/DEV_ARCH.md](../tools/production/docs/DEV_ARCH.md),
(`--firmware`, три сборки этого репозитория) не меняется и по-прежнему §8. Штатный путь (`--firmware`, три сборки этого репозитория, что через
`just host::flash`, что через `service-tui`) не меняется и по-прежнему
использует auto-config Flashloader, как описано в 1.4. использует auto-config Flashloader, как описано в 1.4.
--- ---

View file

@ -4,7 +4,13 @@
> >
> Документ описывает протокол обмена между диагностической прошивкой > Документ описывает протокол обмена между диагностической прошивкой
> (`firmware_test`) и хостовым ПО сервисного инженера. > (`firmware_test`) и хостовым ПО сервисного инженера.
> Актуален для: `firmware_test v0.1.0+`, `protocol.h v2`. > Актуален для: `firmware_test v0.1.2+`, `protocol.h v2`.
>
> Полный справочник по каждому тесту (потоки, коды `detail`, таблица HIL
> реле) — в [firmware/test/README.md](../../firmware/test/README.md) и
> [firmware/test/src/tests/README.md](../../firmware/test/src/tests/README.md).
> Этот документ — сжатый протокольный обзор с точки зрения хостового ПО
> (TUI/pytest), а не полное описание тест-логики.
--- ---
@ -79,7 +85,7 @@ sequenceDiagram
participant T as Таргет participant T as Таргет
Note over T: прошивка загружена через USB SDP Note over T: прошивка загружена через USB SDP
T-->>H: {"type":"session_start","fw":"0.1.0","target":"IMXRT1052","uptime_ms":0} T-->>H: {"type":"session_start","fw":"0.1.2","target":"IMXRT1052","uptime_ms":0}
H->>T: {"type":"cmd","cmd":"ping"} H->>T: {"type":"cmd","cmd":"ping"}
T-->>H: {"type":"pong"} T-->>H: {"type":"pong"}
@ -169,6 +175,17 @@ sequenceDiagram
← {"ok":false,"error":"UID_READ_ERR"} ← {"ok":false,"error":"UID_READ_ERR"}
``` ```
### `get_version` — чтение версии прошивки
```json
→ {"type":"cmd","cmd":"get_version"}
← {"type":"version_response","fw":"0.1.2"}
```
Дублирует значение `"fw"` из `session_start` — полезно, если хост
подключился уже после того, как `session_start` был отправлен (может быть
пропущен, это одноразовое событие сразу после старта).
### `run_selected` — запуск подмножества тестов ### `run_selected` — запуск подмножества тестов
Запускает тесты по списку ID. Порядок выполнения — по реестру таргета, Запускает тесты по списку ID. Порядок выполнения — по реестру таргета,
@ -199,7 +216,7 @@ sequenceDiagram
### `session_start` ### `session_start`
```json ```json
{"type":"session_start","fw":"0.1.0","target":"IMXRT1052","uptime_ms":0} {"type":"session_start","fw":"0.1.2","target":"IMXRT1052","uptime_ms":0}
``` ```
### `test_begin` ### `test_begin`
@ -222,6 +239,11 @@ sequenceDiagram
`detail` — ASCII-строка до 95 символов. При `pass` — пустая. `detail` — ASCII-строка до 95 символов. При `pass` — пустая.
### `test_list`
Ответ на `list_tests` — массив дескрипторов теста (`id`, `name`,
`critical`, `requires_hil`), см. пример в разделе `list_tests` выше.
### `progress` ### `progress`
```json ```json
@ -230,6 +252,11 @@ sequenceDiagram
Промежуточные шаги внутри теста. Используется в `usd`. Промежуточные шаги внутри теста. Используется в `usd`.
### `uid_response` / `version_response`
Ответы на `get_uid`/`get_version` — см. описание соответствующих команд
выше.
### `confirm_request` ### `confirm_request`
```json ```json
@ -270,25 +297,30 @@ sequenceDiagram
| `UNKNOWN_TEST` | Поле `"id"` в `run` или `"tests"` в `run_selected` содержит неизвестный ID | | `UNKNOWN_TEST` | Поле `"id"` в `run` или `"tests"` в `run_selected` содержит неизвестный ID |
| `LINE_TOO_LONG` | Входящая строка превысила 128 байт | | `LINE_TOO_LONG` | Входящая строка превысила 128 байт |
| `BUSY` | Таргет выполняет тест, новая команда отклонена | | `BUSY` | Таргет выполняет тест, новая команда отклонена |
| `UID_READ_ERR` | `bsp_prov_read_uid()` вернул ошибку (ответ на `get_uid`) |
--- ---
## Матрица тестов ## Матрица тестов
Порядок — как в реестре `k_registry[]` (`test_runner.c`); полная версия с
кодами `detail` и HIL-таблицей реле — в
[firmware/test/README.md §Матрица тестов](../../firmware/test/README.md#матрица-тестов).
| ID | Название | Тип | Critical | HIL (M5) | Интерактивный | | ID | Название | Тип | Critical | HIL (M5) | Интерактивный |
| ---------- | --------------------- | ------------------ | -------- | -------- | ------------------ | | --------- | ------------------- | ------------------ | -------- | -------- | --------------------- |
| `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ | | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ |
| `qspi` | QSPI Flash 8 MB | self | ✅ | ❌ | ❌ | | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ |
| `usd` | uSD (SDIO) | self + interactive | ❌ | ❌ | ✅ (вставить карту) | | `usd` | microSD (SDIO) | self + interactive | ❌ | ❌ | ✅ (вставить карту) |
| `display` | Display RGB888 | interactive | ❌ | ❌ | ✅ (цвета R/G/B/W) | | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ (6 шагов, см. ниже) |
| `buttons` | Кнопки Test_But_1/2 | interactive | ❌ | ❌ | ✅ (нажать кнопки) | | `buttons` | Test Buttons | interactive | ❌ | ❌ | ✅ (нажать кнопки) |
| `can` | CAN | HIL | ❌ | ✅ | ❌ | | `opto` | Opto Inputs | HIL | ❌ | ✅ | ❌ (авто, 6 шагов) |
| `uart_ttl` | UART TTL | HIL | ❌ | ✅ | ❌ | | `can` | CAN loopback | HIL | ❌ | ✅ | ❌ (авто, 2 шага) |
| `uart_iso` | UART ISO / RS_RX Opto | HIL | ❌ | ✅ | ❌ | | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ (слышимость тона) |
| `opto` | Opto-in EXT_IN1/IN2 | HIL | ❌ | ✅ | ❌ |
**Типы:** **self** — таргет тестирует периферию самостоятельно; **interactive** **Типы:** **self** — таргет тестирует периферию самостоятельно; **interactive**
требует `confirm_request`; **HIL** — требует M5StampPLC. требует `confirm_request`, отвечает оператор; **HIL** — требует M5StampPLC,
confirm автоматический (без оператора).
--- ---
@ -320,6 +352,10 @@ sequenceDiagram
### Display (RGB888) ### Display (RGB888)
Шесть шагов: Red → Green → Blue → White, затем два ротационных (диагностика
непропаянных LR/UD пинов на TFT7/8/10). Тест прерывается на **первом**
неподтверждённом шаге.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant H as Хост participant H as Хост
@ -333,10 +369,36 @@ sequenceDiagram
T-->>H: {"type":"confirm_request","id":"display_blue","prompt":"Экран залит синим?","timeout_ms":15000} T-->>H: {"type":"confirm_request","id":"display_blue","prompt":"Экран залит синим?","timeout_ms":15000}
H->>T: {"type":"confirm","id":"display_blue","confirmed":true} H->>T: {"type":"confirm","id":"display_blue","confirmed":true}
T-->>H: {"type":"confirm_request","id":"display_white","prompt":"Экран залит белым?","timeout_ms":15000} T-->>H: {"type":"confirm_request","id":"display_white","prompt":"Экран залит белым?","timeout_ms":15000}
H->>T: {"type":"confirm","id":"display_white","confirmed":false} H->>T: {"type":"confirm","id":"display_white","confirmed":true}
T-->>H: {"type":"test_result","id":"display","status":"fail","ms":22103,"detail":"display_white not confirmed"} T-->>H: {"type":"confirm_request","id":"display_rot0","prompt":"Слева КРАСНЫЙ, справа СИНИЙ?","timeout_ms":15000}
H->>T: {"type":"confirm","id":"display_rot0","confirmed":true}
T-->>H: {"type":"confirm_request","id":"display_rot_base","prompt":"Красный/синий поменялись сторонами?","timeout_ms":15000}
H->>T: {"type":"confirm","id":"display_rot_base","confirmed":true}
T-->>H: {"type":"test_result","id":"display","status":"pass","ms":42310,"detail":""}
``` ```
При отказе/таймауте на любом шаге: `status:"fail"`,
`detail:"<id> not confirmed"` (например, `"display_white not confirmed"`).
### MQS Audio Out
Таргет ~4с играет мелодию через MQS + усилитель, затем запрашивает
подтверждение слышимости — единственный тест с аудио-confirm:
```mermaid
sequenceDiagram
participant H as Хост
participant T as Таргет
T-->>H: {"type":"test_begin","id":"mqs",...}
Note over T: ~4с воспроизведение тона (A4, затем E5)
T-->>H: {"type":"confirm_request","id":"mqs_tone","prompt":"Do you hear a tone?","timeout_ms":15000}
H->>T: {"type":"confirm","id":"mqs_tone","confirmed":true}
T-->>H: {"type":"test_result","id":"mqs","status":"pass","ms":19240,"detail":""}
```
Отказ/таймаут → `status:"fail"`, `detail:"operator: no sound"`.
### Кнопки ### Кнопки
```mermaid ```mermaid
@ -393,32 +455,15 @@ firmware/test/src/
├── test_usd.c ├── test_usd.c
├── test_display.c ├── test_display.c
├── test_buttons.c ├── test_buttons.c
├── test_opto.c
├── test_can.c ├── test_can.c
├── test_uart_ttl.c └── test_mqs.c
├── test_uart_iso.c
└── test_opto.c
``` ```
### Добавление нового теста ### Добавление нового теста
1. Создать `firmware/test/src/tests/test_foo.c`. Пошаговый гайд с шаблонами (self-тест, интерактивный, pre-confirm) —
2. Объявить дескриптор: [firmware/test/README.md §Как добавить новый тест](../../firmware/test/README.md#как-добавить-новый-тест).
```c
const test_module_t k_test_foo = {
.id = "foo",
.name = "Foo Peripheral",
.critical = false,
.requires_hil = false,
.pre_confirm_prompt = NULL,
.init = NULL,
.run = test_foo_run,
.deinit = NULL,
};
```
1. Добавить `&k_test_foo` в реестр `test_runner.c`.
2. Добавить `tests/test_foo.c` в `CMakeLists.txt` таргета.
--- ---

View file

@ -166,12 +166,18 @@ ls build/target-debug/tests/target/<name>/test_<name>.elf
## Шаг 5 — `conftest.py`: добавить фикстуры ## Шаг 5 — `conftest.py`: добавить фикстуры
### Базовый тест (без M5) ### Любой тест — фикстура загрузки всегда зависит от `m5`
M5StampPLC управляет питанием таргета (RLY1 → VIN, см. `HIL_BENCH.md`), а
не только сигнальными реле — поэтому `loaded_<n>` зависит от `m5` **во всех
случаях**, даже если сам тест не использует реле для сигналов (например,
`01_test_uart.py`/`loaded_host_uart`). Без этой зависимости pyOCD попытается
подключиться к обесточенной плате.
```python ```python
# 1. Фикстура загрузки # 1. Фикстура загрузки — m5 гарантирует, что питание включено до pyOCD
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def loaded_<n>(request: pytest.FixtureRequest) -> None: def loaded_<n>(request: pytest.FixtureRequest, m5: M5Agent) -> None:
_load_elf( _load_elf(
request, request,
Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf", Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf",
@ -184,26 +190,10 @@ _UART_FIXTURE_MAP = {
} }
``` ```
### Тест с M5 Различие между «базовым» и «с M5» тестом — не в сигнатуре `loaded_<n>`
(она всегда одна и та же), а в том, использует ли сам **тест-кейс**
```python `m5.opto_set()`/`m5.relay_set()`/`m5.can_*()` для управления сигналами
# 1. Зависимость от m5 гарантирует что питание включено до загрузки ELF помимо включения питания (см. пример «Тест с M5» в Шаге 6 ниже).
@pytest.fixture(scope="module")
def loaded_<n>(request: pytest.FixtureRequest, m5: M5Agent) -> None:
_load_elf(
request,
Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf",
)
# 2. UART-фикстура — та же одна строка
_UART_FIXTURE_MAP = {
...
"uart_<n>": "loaded_<n>",
}
```
**Правило:** если тест управляет железом через M5 — `loaded_<n>` должен явно
зависеть от `m5`, иначе pyOCD попытается подключиться до включения питания.
--- ---

View file

@ -1,232 +1,193 @@
# Отладка прошивок через SWD + GDB # Добавление нового host unit-теста
## Обзор архитектуры Пошаговый гайд для разработчика. Полный справочник по Unity/FFF API,
структуре stub-хедеров и типичным ловушкам — в
[tests/host/README.md](../../../tests/host/README.md). Этот документ —
только про шаги добавления нового теста в сборку.
Отладка построена на пробросе GDB-сервера с хоста в devcontainer по TCP. Это ---
позволяет держать весь инструментарий сборки и языковой сервер внутри контейнера,
не проводя USB-пробник внутрь Docker. ## Обзор стека
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph Host["Хост (macOS / Linux)"] subgraph DC["Devcontainer (единственное место запуска)"]
DS["just host::debug-server\npyocd gdbserver :3333"] C["tests/host/&lt;dir&gt;/test_&lt;name&gt;.c\nUnity [+ fff]"]
ML["MCU-Link (CMSIS-DAP)"] CP["CMakePresets.json\nhost-debug / host-release"]
DS --> ML JB["just/build.just\ntest-host"]
C --> CP --> JB
end end
subgraph DC["Devcontainer"]
CD["cortex-debug\n(VSCode F5)"]
GDB["arm-none-eabi-gdb\nсимволы из .elf"]
CD --> GDB
end
Board["MIMXRT1052\nFlash / SDRAM\nSEGGER RTT буфер"]
GDB -->|"TCP host.docker.internal:3333"| DS
ML -->|"SWD"| Board
``` ```
**Ключевой принцип:** `pyocd gdbserver` слушает на `0.0.0.0:3333`. Из контейнера Host-тесты компилируются `clang-17` **на хосте** (не ARM GCC), исполняются
GDB подключается через `host.docker.internal:3333` — специальный DNS-алиас Docker, как обычные нативные бинарники под `ctest`. Никакого железа не требуется —
резолвится в IP хост-машины. в отличие от HIL-тестов (см. [../hil/HIL_CREATE_TEST.md](../hil/HIL_CREATE_TEST.md)).
--- ---
## Компоненты ## Шаг 0 — Определить категорию модуля
### На хосте | Категория | Инструментарий | Пример |
| ----------------------------------------- | ------------------ | ----------------------------------- |
| **A** — платформонезависимый | Только Unity | `protocol.c`, `test_runner.c`, `ring_buffer.c` |
| **B** — BSP-модуль (зависит от NXP SDK) | Unity + fff + stub-хедеры | `bsp/led`, `bsp/opto`, `bsp/can`, `bsp/button` |
| Компонент | Роль | Источник | Полное объяснение разницы и структуры — в
| --------------------------------- | ------------------------------- | -------------------------- | [tests/host/README.md §1](../../../tests/host/README.md#1-две-категории-тестируемых-модулей).
| `pyocd` | GDB-сервер + flash-программатор | `tools/hil/uv.lock` |
| `MCU-Link` | CMSIS-DAP v2 пробник | USB к плате |
| `just host::debug-server` | Запуск GDB-сервера | `just/host.just` |
| `just host::flash-swd-*` | Прошивка через SWD | `just/host.just` |
| `tools/host/flash_swd.py` | Сборка FCB+HAB образа и запись | `tools/host/` |
| `tools/host/dcd/w25q128_fdcb.bin` | FCB для W25Q128 (Quad SPI) | NXP SecureProvisioningTool |
### В devcontainer
| Компонент | Роль |
| -------------------------------------- | ---------------------------------------------- |
| `arm-none-eabi-gdb` | GDB клиент, подключается к серверу на хосте |
| `cortex-debug` (VSCode extension) | UI для GDB: брейкпоинты, стек, регистры |
| `.vscode/launch.json` | Конфигурации запуска отладки |
| `.vscode/tasks.json` | `preLaunchTask` — пересборка ELF перед стартом |
| `build/Debug/*.elf` | Символы для GDB (DWARF debug info) |
| `bsp/generated/startup/MIMXRT1052.xml` | SVD — описание регистров периферии |
### Конфигурация
Параметры отладки задаются в `.env`:
```bash
GDB_PORT=3333
PYOCD_TARGET=mimxrt1050_quadspi
PYOCD_FREQUENCY=4000000
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
```
--- ---
## Поддерживаемые прошивки ## Шаг 1 — Создать тестовый файл
| Конфигурация VSCode | ELF | Особенности |
| ----------------------------- | ------------------------------- | ---------------------------- |
| `🐛 Debug: firmware_test` | `build/Debug/firmware_test.elf` | Bare-metal, входной контроль |
| `🐛 Debug: bootloader` | `build/Debug/bootloader.elf` | Bare-metal, A/B обновление |
| `🐛 Debug: tft_app (FreeRTOS)` | `build/Debug/app.elf` | FreeRTOS, task view |
Все три — XIP-прошивки, исполняются из QuadSPI NOR Flash (`0x60000000`).
---
## Режимы запуска отладки
### Режим А — прошивка уже в Flash
```bash ```bash
# 1. Хост — запустить GDB-сервер (оставить в отдельном терминале) mkdir -p tests/host/<name>/
just host::debug-server touch tests/host/<name>/test_<name>.c
# 2. DevContainer — VSCode
# Run & Debug (Ctrl+Shift+D) → выбрать конфигурацию → F5
``` ```
GDB сбрасывает MCU, загружает символы из ELF и останавливается на входе ### Шаблон — категория A (без моков)
в `main`. Flash не перезаписывается.
### Режим Б — прошить через SWD, затем отладить
```bash
# 1. DevContainer
just build::hab-firmware-test-debug
# 2. Хост
just host::flash-swd-test-debug
# 3. ⚡ Power cycle платы (обязательно)
# 4. Хост
just host::debug-server
# 5. DevContainer — VSCode → 🐛 Debug: firmware_test → F5
```
### Режим В — прошить через USB SDP, затем отладить
```bash
# 1. DevContainer
just build::build-firmware-test-debug
# 2. Хост — перевести плату в SDP-режим, затем:
just host::flash-test-debug
# 3. Хост
just host::debug-server
# 4. DevContainer — VSCode → 🐛 Debug: firmware_test → F5
```
---
## Почему flash через SWD требует FCB
При USB SDP ROM-загрузчик инициализирует FlexSPI по DCD из HAB-образа — FCB
не нужен. При SWD flash-алгоритм pyOCD пишет в NOR Flash напрямую. При
cold-start Boot ROM сначала читает FCB по адресу `0x60000000`, конфигурирует
FlexSPI, и только потом ищет IVT. Без FCB бутлоадер не стартует.
`flash_swd.py` решает это, собирая образ перед записью:
```bash
0x60000000 w25q128_fdcb.bin (512 байт) — FCB
0x60000200 0xFF × 3584 байт — padding
0x60001000 firmware_test_hab.bin — IVT + DCD + код
```
Весь диапазон `0x600000000x6000FFFF` — один 64KB сектор: стирается и
записывается за одну транзакцию.
---
## RTT-логи
SEGGER RTT включён только в Debug-сборках (`SEGGER_RTT_ENABLED=ON`).
После старта отладки вкладка `TERMINAL → RTT` принимает вывод канала 0.
`cortex-debug` находит адрес буфера по символу `_SEGGER_RTT` из ELF.
```c ```c
#include "SEGGER_RTT.h" #include "unity.h"
SEGGER_RTT_printf(0, "value = %d\n", value); #include "<модуль>.h" /* тестируемый модуль */
void setUp(void) { /* сброс состояния если нужен */ }
void tearDown(void) { }
void test_something(void)
{
TEST_ASSERT_EQUAL(expected, actual);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_something);
return UNITY_END();
}
```
### Шаблон — категория B (с fff-фейками)
```c
#include "unity.h"
#include "fff.h"
DEFINE_FFF_GLOBALS; /* ровно один раз на файл */
/* 1. Stub-хедер с типами NXP SDK */
#include "fsl_gpio.h"
/* 2. Фейки для функций, которые вызывает тестируемый модуль */
FAKE_VOID_FUNC(GPIO_PinInit, GPIO_Type *, uint32_t, const gpio_pin_config_t *);
FAKE_VOID_FUNC(GPIO_PinWrite, GPIO_Type *, uint32_t, uint8_t);
/* 3. Тестируемый модуль — ПОСЛЕ фейков */
#include "bsp/<module>.h"
void setUp(void)
{
RESET_FAKE(GPIO_PinInit);
RESET_FAKE(GPIO_PinWrite);
FFF_RESET_HISTORY();
}
void tearDown(void) { }
void test_something(void)
{
TEST_ASSERT_EQUAL_UINT8(0U, GPIO_PinWrite_fake.arg2_val);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_something);
return UNITY_END();
}
```
Если тестируемому модулю не хватает stub-хедера (новый SDK-вызов) —
добавить минимальные типы/сигнатуры в `tests/host/mocks/` (только то, что
реально используется — не копировать весь SDK-хедер).
---
## Шаг 2 — Зарегистрировать в `tests/host/CMakeLists.txt`
```cmake
# категория A — платформонезависимый, без MOCKS
add_host_test(
NAME test_<name>
SOURCES <name>/test_<name>.c
${PROJECT_SOURCE_DIR}/<путь-к-модулю>/<module>.c
INCLUDES ${PROJECT_SOURCE_DIR}/<путь-к-инклюдам>
)
# категория B — BSP-модуль, нужны MOCKS
add_host_test(
NAME test_<name>
SOURCES <name>/test_<name>.c
${PROJECT_SOURCE_DIR}/bsp/<name>/src/<name>.c
INCLUDES ${PROJECT_SOURCE_DIR}/bsp/<name>/include
${PROJECT_SOURCE_DIR}/bsp/common/include
MOCKS ${BSP_MOCKS_DIR}
)
```
`add_host_test()` — вспомогательная CMake-функция, определённая в начале
того же файла (`NAME`/`SOURCES`/`INCLUDES`/`MOCKS`). Каждый тест — свой
исполняемый файл; `MOCKS` подключает `tests/host/mocks/` в include path
**раньше** реального SDK, `INCLUDES` — явные пути, специфичные для теста
(без скрытых глобальных путей). Если модуль использует `bsp_uart_host` через
готовый мок — смотри пример `uart_host_mock_example` в том же файле.
Если тест компилируется с seam-макросом (как `test_runner.c` с
`-DUNIT_TEST`, см. `firmware/test/README.md` §UNIT_TEST seam) — добавить:
```cmake
target_compile_definitions(test_<name> PRIVATE UNIT_TEST)
``` ```
--- ---
## FreeRTOS task view ## Шаг 3 — Собрать и прогнать
Конфигурация `🐛 Debug: tft_app (FreeRTOS)` включает `"rtos": "FreeRTOS"`
cortex-debug разбирает структуры планировщика и показывает вкладку `RTOS`
с таблицей задач: имя, состояние, использование стека, приоритет.
---
## Просмотр регистров периферии
Вкладка `Peripherals` показывает все блоки MIMXRT1052 по SVD-файлу
`bsp/generated/startup/MIMXRT1052.xml`. Значения обновляются при каждой паузе.
---
## Ограничения
**MCU-Link монопольный ресурс.** `debug-server` и `flash-swd` не могут
работать одновременно. Перед `flash-swd` остановите сервер (Ctrl+C).
**HIL-тесты vs отладка.** pyOCD также используется для HIL. Перед
`just host::hil-run` остановите GDB-сервер.
**Power cycle после flash-swd обязателен.** VECTRESET не реинициализирует
FlexSPI — только полное отключение питания гарантирует корректный cold-start.
**Только Debug-сборки.** Release компилируется с `-O2` без DWARF-символов.
---
## Быстрый старт (первый запуск)
```bash ```bash
# 1. Убедиться что в .devcontainer/devcontainer.json есть (для Linux): # конфигурация (один раз или после изменения CMakeLists)
# "runArgs": ["--add-host=host.docker.internal:host-gateway"] cmake --preset host-debug
# 2. Залить прошивку # сборка + тесты одной командой
just host::flash-test-debug just build::test-host
# 3. Хост — запустить GDB-сервер # конкретный тест с полным выводом Unity
just host::debug-server ctest --preset host-debug-test -R test_<name> -V
# 4. DevContainer — VSCode # напрямую — без обёртки CTest
# Ctrl+Shift+D → 🐛 Debug: firmware_test → F5 ./build/host-debug/tests/host/test_<name>
```
`just build::test-host` собирает под пресетом `host-debug` (`clang-17`,
без ARM-специфики) и прогоняет весь набор через CTest. `host-release`
собирает тот же набор с оптимизациями — используется в CI как
дополнительный гейт.
---
## Чеклист
```bash
[ ] tests/host/<name>/test_<name>.c — тест-файл (категория A или B)
[ ] tests/host/mocks/*.h — новый stub-хедер, если модуль
использует ранее не замоканный SDK-вызов
[ ] tests/host/CMakeLists.txt — add_host_test(...) для нового теста
[ ] just build::test-host — зелёная сборка + прогон
``` ```
--- ---
## Дерево файлов отладки ## Справочник
```bash Полный API Unity (assertion-макросы), fff (создание фейков, `custom_fake`,
. проверка вызовов), работа со stub-хедерами и типичные ловушки (dangling
├── .env # GDB_PORT, PYOCD_TARGET, PYOCD_FREQUENCY, FCB_PATH pointer из `arg_history`, `static`-функции, `ScopeMismatch`-аналоги для
├── .vscode/ host-тестов) — в [tests/host/README.md](../../../tests/host/README.md).
│ ├── launch.json # cortex-debug конфигурации (3 проекта)
│ └── tasks.json # preLaunchTask: build:*-debug
├── bsp/generated/startup/
│ └── MIMXRT1052.xml # SVD — регистры периферии
├── just/
│ └── host.just # debug-server, flash-swd-*
└── tools/
├── hil/ # uv-проект с pyocd
└── host/
├── flash_swd.py # FCB + HAB → Flash через pyOCD
└── dcd/
└── w25q128_fdcb.bin # FCB для W25Q128 Quad SPI
```

View file

@ -1,488 +0,0 @@
# firmware_test — План разработки
> Версия: 0.8 | Обновлён после завершения Этапа 6г (bsp_mqs + test_mqs, hardware-verified).
---
## Контекст проекта
**Цель прошивки:** диагностика платы MIMXRT1052CVJ5B на сервисе (возврат по рекламации).
Запускается через BootROM (USB SDP), без предварительной прошивки загрузчика.
**Стенд:**
- Хост подключается через USB CDC ACM — единственный канал firmware_test
- HIL-тесты управляются через M5StampPLC (опционально)
- TUI-приложение оркестрирует и firmware_test (CDC), и M5 (Serial) одновременно
---
## Текущий статус
| Компонент | Статус | Примечание |
| ------------------------------ | ------ | ------------------------------------------------ |
| `bsp_usb_cdc` | ✅ | HIL тест пройден |
| firmware_test скелет | ✅ | `main.c` + `cli.c` |
| Протокол v2 + test_runner | ✅ | JSON-lines event-driven |
| `bsp_sdram` + `test_sdram` | ✅ | 4 фазы: addr/data/seq/retention |
| `bsp_qspi_flash` + `test_qspi` | ✅ | JEDEC + erase + rw + addr range |
| `bsp_sd` + `test_usd` | ✅ | bsp_sd + FatFS, pre_confirm, 4 шага |
| `bsp_display` + `test_display` | ✅ | 4 цвета + ротация, hardware-verified |
| `bsp_button` + `test_buttons` | ✅ | 2 кнопки, physical detect, hardware-verified |
| Протокол: `list_tests` | ✅ | Этап 6а, hardware-verified |
| Протокол: `run_selected` | ✅ | Этап 6а, hardware-verified |
| `test_opto` | ✅ | Этап 6б, hardware-verified |
| `test_can` | ✅ | Этап 6в, hardware-verified |
| HIL pytest firmware_cdc | ✅ | Этап 6д, `FirmwareCdc` + `firmware_cdc` фикстура |
| HIL pytest firmware_opto | ✅ | Этап 6е, `06_test_firmware_opto.py` |
| HIL pytest firmware_can | ✅ | Этап 6ж, `06_test_firmware_can.py` |
| `bsp_mqs` + `test_mqs` | ✅ | Этап 6г, hardware-verified |
| Provisioning | ⬜ | Этап 7 |
| TUI сервисного инженера | ⬜ | Этап 8 |
---
## Матрица тестов — итоговая
| ID | Название | Critical | HIL | Тип | BSP | Статус |
| --------- | ------------------ | -------- | --- | ----------- | ------------------ | ------ |
| `sdram` | SDRAM 32 MB | ✅ | ❌ | self | `bsp_sdram` ✅ | ✅ |
| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | self | `bsp_qspi_flash` ✅ | ✅ |
| `usd` | microSD (SDIO) | ❌ | ❌ | interactive | `bsp_sd` ✅ | ✅ |
| `display` | TFT Display RGB888 | ❌ | ❌ | interactive | `bsp_display` ✅ | ✅ |
| `buttons` | Test Buttons 1/2 | ❌ | ❌ | interactive | `bsp_button` ✅ | ✅ |
| `mqs` | MQS Audio Out | ❌ | ❌ | interactive | `bsp_mqs` ✅ | ✅ |
| `can` | CAN loopback | ❌ | ✅ | HIL | `bsp_can` ✅ | ✅ |
| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | HIL | `bsp_opto` ✅ | ✅ |
**Убранные тесты (закрытые решения):**
- `uart_ttl` — LPUART1 dev-инструмент (MCU-Link VCOM), в сервисе не используется
- `uart_iso` — RS_RX физически тот же пин что IN в `test_opto`, избыточно
---
## Закрытые архитектурные решения
> Не пересматривать без явного запроса.
### Этапы 15 (ранее зафиксированные)
- **Транспорт:** USB CDC ACM — единственный канал. UART не используется в firmware_test.
- **Парсинг JSON:** без cJSON, строковый `strstr`. Входящее поле всегда `"type"` / `"cmd"`.
- **SDRAM и DCD:** SEMC инициализируется DCD до `main()`. `bsp_sdram_init()` только верифицирует.
- **QSPI-функции в ITCM:** `AT_QUICKACCESS_SECTION_CODE` + `__STARTUP_INITIALIZE_RAMFUNCTION`.
- **W25Q256/512:** dedicated 4-byte opcodes, без Enter 4-Byte Mode (0xB7).
- **bsp_button_init():** вызывается в `init()` тест-модуля, не в `main.c`.
- **Тест дисплея:** 4 цвета + 2 ротации. Таймаут confirm 15 с → FAIL.
- **Тест кнопок:** физическая детекция через `bsp_button`. Хост не отправляет JSON confirm. Таймаут 10 с → SKIP.
### Этап 6 (новые решения)
- **Разделение тестов:** `requires_hil=false` (standalone) vs `requires_hil=true` (HIL).
TUI фильтрует HIL-тесты если M5StampPLC не подключён.
- **`list_tests`:** таргет отдаёт реестр тестов с метаданными по запросу хоста.
TUI строит UI динамически, не хардкодит список тестов.
- **`run_selected`:** запуск произвольного подмножества тестов по списку ID.
Порядок выполнения — как в реестре таргета, не как в запросе.
Таргет принимает любой список без проверки `requires_hil` — ответственность на TUI.
- **TUI оркестрирует M5:** firmware_test не знает про M5. При `confirm_request`
от HIL-теста TUI командует M5, получает результат, отправляет confirm.
- **M5 опционален:** TUI при старте пробует найти M5. Не нашёл — HIL-тесты
недоступны (серые в UI, не входят в `run_selected`).
- **Фильтрация HIL на стороне TUI:** таргет не фильтрует по `requires_hil`.
- **MQS стерео:** MQS MIMXRT1052 требует стерео PCM-буфер. На плате выведен
один канал. Буфер всегда стерео (L+R идентичны).
- **MQS тест:** захардкоженная мелодия ~4 с (A4 + E5, по 2 с каждая),
`confirm_request("mqs_tone")`, оператор слышит → PASS/FAIL.
`critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`.
- **MQS порядок init:** `bsp_mqs_amp_init()``bsp_delay(300)``bsp_mqs_init()`.
Усилитель запускается первым, чтобы успели зарядиться конденсаторы C103/C105 LM4875M.
Нарушение порядка приводит к щелчку при старте или отсутствию звука.
- **MQS USB keepalive:** воспроизведение через `bsp_mqs_play()` (async, не blocking),
параллельно крутится `bsp_usb_cdc_poll()`. Blocking-вариант голодает USB за ~4 с.
- **`pwmchannelenable` (NXP SDK ≥ 2.13):** поле в `pwm_signal_param_t` обязательно
выставлять в `true`. При инициализации через designated initializers без явного
указания равно `false``PWM_SetupPwm()` не выставляет `OUTEN` → ШИМ не выходит
на пин. Маскируется после отладочной сессии (отладчик оставляет `OUTEN` от прошлого
прогона). Воспроизводится только при cold reset.
- **ERRATA 50235 (FlexCAN + USB):** `FLEXCAN_Init()` содержит assert на
`CCM_CCGR5_CG12` (LPUART clock gate). После `bsp_usb_cdc_init()` gate
может быть закрыт → assert → HardFault. Workaround: `CLOCK_EnableClock(kCLOCK_Lpuart1)`
перед `FLEXCAN_Init()` внутри `bsp_can_init()`. Gate оставляется открытым —
закрывать не нужно, LPUART1 тактируется с минимальным потреблением.
`bsp_can_init()` вызывается из `main()` после `bsp_usb_cdc_init()`.
- **`firmware_cdc` фикстура:** не ждёт `session_start` (одноразовое событие при
старте, может быть пропущено). Проверяет живость через `ping → pong`.
- **`bsp_opto_force_read()`:** добавлен в BSP API для синхронного чтения пина
без дебаунса. Обновляет `confirmed_state`, сбрасывает `pending`. Используется
в `test_opto.c` после settle — обходит race condition когда чётное число ISR
при дребезге реле оставляет `pending=false` с устаревшим `confirmed_state`.
- **`bsp_opto_process()` в `test_opto.c`:** вызывается в settle loop после confirm,
но не в `test_runner_wait_confirm()`. Финальное чтение — через `bsp_opto_force_read()`.
- **Оркестратор оpto:** `RELAY_ON_S=0.15`, `RELAY_OFF_S=0.5` в `06_test_firmware_opto.py`.
Фиксированный sleep достаточен — реле переключается до отправки `confirmed:true`,
`bsp_opto_force_read()` читает финальное состояние пина напрямую.
### Этап 8 (TUI решения)
- **Прошивка — только USB SDP:** SWD недоступен сервиснику. spsdk (sdphost + blhost).
Оператор сам переставляет перемычку BOOT — это ок, документируется.
- **TUI автодетект:** определяет подключение по VID/PID — SDP BootROM (1FC9:0130)
или CDC firmware_test (session_start) — и показывает соответствующий экран.
- **Фреймворк TUI:** Textual (Python). Нативный async, реальные виджеты,
работает в SSH-сессии, вписывается в uv-экосистему.
- **tools/shared/m5_agent.py:** общая M5-логика, импортируется из `tools/hil/`
и `tools/production/`.
---
## Этап 6 — test_can + test_opto + test_mqs + протокол ✅ ЗАВЕРШЁН
### 6а — Расширение протокола ✅
**Файлы:** `protocol.h`, `protocol.c`, `cli.c`, `test_runner.c`, `PROTOCOL.md`
#### Новая команда `list_tests`
```json
→ {"type":"cmd","cmd":"list_tests"}
← {"type":"test_list","tests":[
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
{"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false},
{"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false},
{"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false},
{"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false},
{"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false},
{"id":"can","name":"CAN loopback","critical":false,"requires_hil":true},
{"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true}
]}
```
#### Новая команда `run_selected`
```json
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","qspi","display"]}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi",...}
← {"type":"test_begin","id":"display",...}
← {"type":"test_result","id":"display",...}
← {"type":"summary","passed":3,"failed":0,"skipped":0,"overall":"pass"}
```
Если хотя бы один ID не найден в реестре:
```json
← {"ok":false,"error":"UNKNOWN_TEST"}
```
**Реализация в `test_runner.c`:**
- Новый режим `RUNNER_MODE_SELECTED`
- Статический bool-массив `g_s_selected[REGISTRY_SIZE]` — без malloc
- `test_runner_run_selected(const char **pp_ids, size_t count)` — новая публичная функция
### 6б — test_opto.c ✅
**Файл:** `firmware/test/src/tests/test_opto.c`
6 шагов, попарно ACTIVE/INACTIVE для трёх каналов:
| Шаг | confirm_request id | M5 действие | Проверка |
| --- | ------------------- | ----------- | -------------------------------- |
| 1 | `opto_in1_active` | RLY3 ON | `bsp_opto_read(IN1) == ACTIVE` |
| 2 | `opto_in1_inactive` | RLY3 OFF | `bsp_opto_read(IN1) == INACTIVE` |
| 3 | `opto_in2_active` | RLY4 ON | `bsp_opto_read(IN2) == ACTIVE` |
| 4 | `opto_in2_inactive` | RLY4 OFF | `bsp_opto_read(IN2) == INACTIVE` |
| 5 | `opto_rs_active` | RLY2 ON | `bsp_opto_read(RS) == ACTIVE` |
| 6 | `opto_rs_inactive` | RLY2 OFF | `bsp_opto_read(RS) == INACTIVE` |
- Init: `bsp_opto_init()` единым вызовом для всех каналов
- Верификация синхронная после confirm (M5 переключил реле до отправки `confirmed:true`)
- FAIL при несоответствии: `detail = "<id> state mismatch: expected ACTIVE got INACTIVE"`
- Таймаут: `PROTOCOL_CONFIRM_TIMEOUT_MS` (30 с) на каждый шаг
### 6в — test_can.c ✅
**Файл:** `firmware/test/src/tests/test_can.c`
2 шага, оба направления независимо:
**Шаг 1 — RX (M5 → таргет):**
```bash
confirm_request("can_rx_ready")
→ TUI: M5.can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF])
→ TUI: confirm(true)
→ таргет: bsp_can_receive(&frame, 500 мс)
→ верификация: frame.id==0x100, frame.data==[0xDE,0xAD,0xBE,0xEF]
→ FAIL если timeout или несовпадение
```
**Шаг 2 — TX (таргет → M5):**
```bash
bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс)
confirm_request("can_tx_verify")
→ TUI: M5.can_recv(timeout=500 мс) → верификация id+data
→ TUI: confirm(true) если M5 принял корректно, confirm(false) если нет
→ FAIL если confirmed=false или timeout
```
- `disableSelfReception=true` — таргет не слышит свой TX, только M5 верифицирует
- Init: `bsp_can_init(&cfg)` + `bsp_can_accept_all()`
### 6г — bsp_mqs + test_mqs.c ✅
**Файлы:** `bsp/mqs/` + `firmware/test/src/tests/test_mqs.c`
**bsp_mqs:**
- SAI3 + eDMA (DMA0 канал 0) + MQS периферия
- Стерео PCM16 буфер (L+R идентичны), один физический выход `MQS_RIGHT`
- Усилитель LM4875M управляется PWM4 SM0 через RC-фильтр и буферный ОУ LM358
- API: `bsp_mqs_init/deinit`, `bsp_mqs_play/play_blocking`, `bsp_mqs_stop`,
`bsp_mqs_is_busy`, `bsp_mqs_amp_init/deinit`, `bsp_mqs_amp_set_volume`
**test_mqs:**
- Мелодия ~4 с: A4 (440 Гц) + E5 (659 Гц), по 2 с каждая, целочисленная LUT-синусоида
- Воспроизведение через `bsp_mqs_play()` (async) с `bsp_usb_cdc_poll()` в цикле
- `confirm_request("mqs_tone", "Do you hear a tone?", 15000)` → PASS/FAIL
- Порядок init: amp → delay 300 мс → mqs → build_melody (однократно, флаг)
- `critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`
### 6д — HIL pytest для firmware_test ✅
**Файлы:**
```
tools/hil/conftest.py ← фикстура firmware_cdc
tools/hil/06_test_firmware_opto.py
tools/hil/06_test_firmware_can.py
```
**Фикстура `firmware_cdc`:**
```python
@pytest.fixture(scope="module")
def firmware_cdc(m5):
"""
Открывает USB CDC порт firmware_test.
firmware_test уже прошит в Flash (не загружается pyOCD).
Проверяет живость через ping → pong.
"""
```
**`FirmwareCdcClient`** — тонкий клиент:
- `send_cmd(cmd_dict)` — отправить JSON команду
- `wait_event(type, timeout_s)` — ждать события нужного типа
- `confirm(id, ok)` — отправить `{"type":"confirm","id":"...","confirmed":true/false}`
- `run_test(id)` — запустить тест, вернуть test_result dict
**Justfile:**
```bash
hil-firmware-opto → pytest 06_test_firmware_opto.py -v
hil-firmware-can → pytest 06_test_firmware_can.py -v
```
---
## Этап 7 — Provisioning
### Что нужно
1. Читать `OCOTP_UNIQUE_ID` через SDK `fsl_ocotp`
2. Отправить `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary`
3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста
4. Записывать статус в Flash (первый сектор после прошивки, вне XIP)
### BSP (предварительно)
```c
/* bsp/provisioning/include/bsp/provisioning.h */
bsp_status_t bsp_prov_read_uid(uint8_t *p_uid, size_t len); /* 8 байт из OCOTP */
```
### Открытые вопросы — Этап 7
- [ ] Что именно записывать как «пройдено»: флаг в Flash или только отправить UID?
- [ ] Нужна ли защита от повторного provisioning (write-once)?
---
## Этап 8 — TUI сервисного инженера
### Стек технологий
| Компонент | Выбор | Обоснование |
| ------------- | ------------- | ----------------------------------------------------- |
| TUI фреймворк | **Textual** | Нативный async, виджеты, SSH-совместим, uv-экосистема |
| Serial | pyserial | Уже в стеке (tools/hil) |
| Прошивка | spsdk | sdphost + blhost, уже в tools/host |
| Конфигурация | python-dotenv | .env файл, совместим с существующим подходом |
### Структура приложения
```bash
tools/production/
├── pyproject.toml ← зависимости: textual, pyserial, spsdk, python-dotenv
├── uv.lock
├── main.py ← точка входа
├── app/
│ ├── tui.py ← Textual App, экраны, layout
│ ├── firmware_client.py ← USB CDC asyncio клиент firmware_test
│ ├── m5_client.py ← M5 Serial клиент (импортирует tools/shared/m5_agent.py)
│ ├── flasher.py ← USB SDP обёртка над spsdk
│ ├── orchestrator.py ← confirm_request → M5 action → confirm response
│ └── models.py ← TestInfo, TestResult, SessionState (dataclasses)
└── README.md
tools/shared/
└── m5_agent.py ← общая M5-логика для hil/ и production/
```
### Два режима работы
**Режим A — Прошивка** (триггер: VID/PID 1FC9:0130 обнаружен — BootROM SDP)
```
┌─ Прошивка платы ─────────────────────────────────┐
│ Обнаружен BootROM (SDP режим) │
│ │
│ Что прошить? │
│ ◉ firmware_test (диагностика) │
│ ○ Production (bootloader + tft_app) │
│ │
│ Файл: [/path/to/firmware_test_hab.bin ···] │
│ │
│ [ Прошить ] │
│ │
│ ████████████░░░░░░ 64% Запись во Flash... │
└────────────────────────────────────────────────────┘
```
**Режим B — Диагностика** (триггер: session_start получен по CDC)
```
┌─ Диагностика платы fw:0.1.0 ─────────────────────┐
│ M5StampPLC: ✓ подключён │ Плата: IMXRT1052 │
├────────────────────────────────────────────────────┤
│ Выбор тестов: │ Результаты: │
│ ☑ SDRAM 32 MB │ sdram ✓ PASS │
│ ☑ QSPI Flash │ qspi ✓ PASS │
│ ☑ microSD │ usd ✗ FAIL │
│ ☑ TFT Display │ mount failed: 5 │
│ ☑ Кнопки │ display ✓ PASS │
│ ☑ MQS Audio │ buttons ✓ PASS │
│ ☑ CAN loopback [HIL] │ mqs ✓ PASS │
│ ☑ Оптовходы [HIL] │ ... │
├────────────────────────────────────────────────────┤
│ [ Запустить выбранные ] [ Все тесты ] │
│ ████████████████░░░░ 80% Тест: display │
├────────────────────────────────────────────────────┤
│ ⚠ Экран залит красным цветом? │
│ [ ✓ Да ] [ ✗ Нет ] │
└────────────────────────────────────────────────────┘
```
### Поведение confirm_request в TUI
| Тип теста | Источник confirm | Действие TUI |
| -------------------- | ------------------ | --------------------------------------------- |
| standalone (display) | оператор | показать prompt, кнопки OK/FAIL, countdown |
| standalone (mqs) | оператор | показать prompt, кнопки OK/FAIL, countdown |
| standalone (buttons) | физическое нажатие | показать инструкцию, ждать test_result |
| HIL (opto, can) | оркестратор | auto: M5 action → confirm (оператор не видит) |
HIL confirm полностью автоматический — оператор видит только прогресс, не интерактивный prompt.
### Конфигурация (.env)
```ini
# Существующие переменные (tools/hil/.env):
HIL_VCOM_PORT=/dev/ttyACM0
HIL_M5_PORT=/dev/ttyACM1
# Новые переменные для production TUI:
SERVICE_CDC_PORT=AUTO # AUTO = автодетект по session_start
SERVICE_M5_PORT=AUTO # AUTO = автодетект, пусто = без M5
FIRMWARE_TEST_BIN=build/Release/firmware_test_hab.bin
PRODUCTION_BIN_BOOT=build/Release/bootloader_hab.bin
PRODUCTION_BIN_APP=build/Release/tft_app_hab.bin
```
### Запуск
```bash
just host::service-tui # запустить TUI сервисного инженера
just host::service-flash <bin> # прошить без TUI (для автоматизации)
```
### Процесс работы сервисника
**Диагностика (firmware_test уже в Flash):**
```bash
1. Плата в нормальном режиме (BOOT_MOD_1 → GND)
2. Подключить USB к сервисному ПК
3. just host::service-tui → TUI обнаружил session_start → Режим B
4. Выбрать тесты → Запустить → Смотреть результаты
```
**Перепрошивка (нужна новая версия firmware_test или production):**
```bash
1. Перемычка BOOT_MOD_1 → 3V3
2. Reset, подключить USB
3. TUI обнаружил 1FC9:0130 → Режим A
4. Выбрать бинарь → Прошить
5. Перемычка BOOT_MOD_1 → GND → Reset → TUI переходит в Режим B
```
---
## Порядок реализации
```
✅ Этап 1 протокол v2 + runner
✅ Этап 2 bsp_sdram + test_sdram
✅ Этап 3 bsp_qspi_flash + test_qspi
✅ Этап 4 bsp_sd + test_usd
✅ Этап 5 display + buttons
✅ Этап 6а протокол: list_tests + run_selected
✅ Этап 6б test_opto.c + hardware верификация
✅ Этап 6в test_can.c + hardware верификация
✅ Этап 6д HIL pytest: firmware_cdc фикстура (FirmwareCdc + firmware_cdc)
✅ Этап 6е HIL pytest: 06_test_firmware_opto.py
✅ Этап 6ж HIL pytest: 06_test_firmware_can.py
✅ Этап 6г bsp_mqs + test_mqs.c + hardware верификация
⬜ Этап 7 Provisioning (OCOTP UID + Flash-флаг) ← СЛЕДУЮЩИЙ ШАГ
⬜ Этап 8а tools/production/ скелет + models + clients
⬜ Этап 8б orchestrator + базовый Textual UI (список тестов, запуск, результаты)
⬜ Этап 8в Экран прошивки (flasher + SDP автодетект)
⬜ Этап 8г Provisioning в TUI
⬜ Этап 8д tools/shared/m5_agent.py (рефакторинг общей M5-логики)
⬜ Этап 9 Параллельно: обновить README + DEV_ARCH.md под финальную архитектуру
```
---
## Зависимости между этапами
```
✅ 6а (протокол) → ✅ 6б (opto) → ✅ 6в (can) → ✅ 6г (mqs)
✅ 6д (conftest) → ✅ 6е (opto pytest) → ✅ 6ж (can pytest)
⬜ 7 (provisioning)
⬜ 8 (TUI)
```

View file

@ -1,10 +1,12 @@
# firmware_test # firmware_test
> Диагностическая прошивка для плат **MIMXRT1052CVJ5B**, вернувшихся по > Диагностическая прошивка для плат TFT индикаторов, вернувшихся по
> рекламации. Запускается сервисным инженером через USB CDC ACM без > рекламации. Загружается на таргет сервисным инженером через USB (через BootROM IMXRT1052).
> предварительной прошивки загрузчика.
> >
> Версия прошивки: `0.1.0` | Протокол: v2 >Версия прошивки: `0.1.2` | Протокол: v2
>
>Версия — из `project(firmware_test VERSION X.Y.Z)` в `CMakeLists.txt`
> (см. [Версионирование](#версионирование))
--- ---
@ -27,6 +29,7 @@
- [Как добавить новый тест](#как-добавить-новый-тест) - [Как добавить новый тест](#как-добавить-новый-тест)
- [Host unit-тесты](#host-unit-тесты) - [Host unit-тесты](#host-unit-тесты)
- [Версионирование](#версионирование) - [Версионирование](#версионирование)
- [Архитектурные решения (закрыты)](#архитектурные-решения-закрыты)
--- ---
@ -45,7 +48,7 @@ just build::hab-firmware-test-debug
# Перевести плату в SDP-режим: BOOT_MOD_1 → 3V3 → Reset # Перевести плату в SDP-режим: BOOT_MOD_1 → 3V3 → Reset
just host::flash-test-debug just host::flash-test-debug
# Или через SWD (power cycle после) # Или через SWD
just host::flash-swd-test-debug just host::flash-swd-test-debug
``` ```
@ -66,7 +69,7 @@ screen /dev/ttyACM0
После подключения таргет сразу присылает: После подключения таргет сразу присылает:
```json ```json
{"type":"session_start","fw":"0.1.0","target":"IMXRT1052","uptime_ms":0} {"type":"session_start","fw":"0.1.2","target":"IMXRT1052","uptime_ms":0}
``` ```
Проверка связи: Проверка связи:
@ -81,7 +84,7 @@ screen /dev/ttyACM0
```json ```json
→ {"type":"cmd","cmd":"run","id":"sdram"} → {"type":"cmd","cmd":"run","id":"sdram"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true} ← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""} ← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
``` ```
Запуск всех тестов: Запуск всех тестов:
@ -110,12 +113,12 @@ just build::test-host
│ JSON-lines, 1 строка = 1 сообщение │ JSON-lines, 1 строка = 1 сообщение
[Плата MIMXRT1052 с firmware_test] [Плата MIMXRT1052 с firmware_test]
│ GPIO / LPUART / SEMC / FlexSPI / USDHC │ GPIO / LPUART / SEMC / FlexSPI / USDHC / SAI(MQS)
[Периферия: SDRAM, QSPI Flash, uSD, Display, Кнопки, CAN, UART, Opto] [Периферия: SDRAM, QSPI Flash, uSD, Display, Кнопки, CAN, MQS, Opto]
[M5StampPLC — управление внешними сигналами для HIL тестов] [M5StampPLC — управление внешними сигналами для HIL тестов]
(реле → EXT_IN1/IN2, RS_RX, CAN, UART echo) (реле → EXT_IN1/IN2, RS_RX; CAN loopback)
``` ```
**Принцип разделения ответственности:** **Принцип разделения ответственности:**
@ -123,8 +126,8 @@ just build::test-host
- Вся тест-логика живёт **на таргете** (`test_runner.c`, `tests/*.c`). - Вся тест-логика живёт **на таргете** (`test_runner.c`, `tests/*.c`).
- Хост — тонкий клиент: отправляет команды, отображает события, управляет - Хост — тонкий клиент: отправляет команды, отображает события, управляет
интерактивными шагами через `confirm`. интерактивными шагами через `confirm`.
- Тесты **атомарны**: инженер запускает один тест или все сразу — порядок - Тесты **атомарны**: инженер запускает один тест, подмножество или все сразу —
не фиксирован. порядок не фиксирован.
--- ---
@ -136,6 +139,8 @@ firmware/test/
└── src/ └── src/
├── main.c — инициализация BSP, главный цикл ├── main.c — инициализация BSP, главный цикл
├── version.h.in — шаблон версии (CMake → generated/version.h)
├── cli.h / cli.c — IO-слой ├── cli.h / cli.c — IO-слой
│ буферизация строк, парсинг "type", │ буферизация строк, парсинг "type",
│ диспатч на test_runner / protocol │ диспатч на test_runner / protocol
@ -149,20 +154,23 @@ firmware/test/
├── test_runner.h / .c — реестр + state machine ├── test_runner.h / .c — реестр + state machine
│ IDLE → PRE_CONFIRM → RUNNING → IDLE │ IDLE → PRE_CONFIRM → RUNNING → IDLE
│ test_runner_wait_confirm() для display │ test_runner_wait_confirm() для in-run confirm
└── tests/ └── tests/
├── test_sdram.c — SDRAM 32 MB (self) ├── test_sdram.c — SDRAM 32 MB (self)
├── test_qspi.c — QSPI Flash 8 MB (self) ├── test_qspi.c — QSPI Flash W25Qxx (self)
├── test_usd.c — uSD SDIO (interactive) ├── test_usd.c — microSD SDIO (interactive, pre-confirm)
├── test_display.c — Display RGB888 (interactive) ├── test_display.c — Display RGB888 (interactive, in-run confirm)
├── test_buttons.c — Test_But_1/2 (interactive) ├── test_buttons.c — Test_But_1/2 (interactive, физическое нажатие)
├── test_opto.c — Opto-in EXT_IN1/IN2 + RS_RX (HIL)
├── test_can.c — CAN loopback (HIL) ├── test_can.c — CAN loopback (HIL)
├── test_uart_ttl.c — UART TTL (HIL) └── test_mqs.c — MQS Audio Out (interactive, in-run confirm)
├── test_uart_iso.c — UART ISO / RS_RX Opto (HIL)
└── test_opto.c — Opto-in EXT_IN1/IN2 (HIL)
``` ```
> Порядок файлов в `tests/` — как в `CMakeLists.txt`. Порядок **выполнения**
> тестов определяется реестром `k_registry[]` в `test_runner.c`
> (см. [Матрица тестов](#матрица-тестов)).
--- ---
### Граф зависимостей ### Граф зависимостей
@ -175,8 +183,9 @@ main.c
├── bsp_usb_cdc (USB CDC ACM, единственный транспорт) ├── bsp_usb_cdc (USB CDC ACM, единственный транспорт)
├── cli.c ├── cli.c
│ └── bsp_usb_cdc (read / write) │ └── bsp_usb_cdc (read / write)
│ └── protocol.c (send_error, send_pong) │ └── bsp_provisioning (bsp_prov_read_uid — для get_uid)
│ └── test_runner.c (run_single, run_all, on_confirm) │ └── protocol.c (send_error, send_pong, send_uid/version_response)
│ └── test_runner.c (run_single, run_all, run_selected, send_list, on_confirm)
├── protocol.c ├── protocol.c
│ └── cli.c (cli_send) │ └── cli.c (cli_send)
│ └── bsp_tick (bsp_tick_get_ms — для uptime) │ └── bsp_tick (bsp_tick_get_ms — для uptime)
@ -188,26 +197,28 @@ main.c
└── tests/*.c (тест-модули через реестр) └── tests/*.c (тест-модули через реестр)
``` ```
**BSP-зависимости тест-модулей:** **BSP-зависимости тест-модулей** (по `target_link_libraries` в `CMakeLists.txt`):
| Тест | BSP модуль | | Тест | BSP модуль |
| --------------- | ------------------------------ | | -------------- | ------------------------------ |
| `test_sdram` | `bsp_sdram` | | `test_sdram` | `bsp_sdram` |
| `test_qspi` | `bsp_qspi` | | `test_qspi` | `bsp_qspi_flash` |
| `test_usd` | `bsp_usd` | | `test_usd` | `bsp_sd` (+ `firmware_test_fatfs`) |
| `test_display` | существующий display BSP | | `test_display` | `bsp_display` |
| `test_buttons` | `bsp_button` ✅ | | `test_buttons` | `bsp_button` |
| `test_can` | `bsp_can` ✅ | | `test_opto` | `bsp_opto` (rs_as_gpio=true) |
| `test_uart_ttl` | `bsp_uart_host` ✅ | | `test_can` | `bsp_can` |
| `test_uart_iso` | `bsp_opto` (rs_as_gpio=true) ✅ | | `test_mqs` | `bsp_mqs` |
| `test_opto` | `bsp_opto` ✅ |
> `bsp_uart_host` также линкуется (используется вне тест-реестра); отдельного
> UART-тест-модуля в текущем реестре нет (тестируется в `tests/target`).
--- ---
### State machine test_runner ### State machine test_runner
```bash ```bash
cmd: run / run_all cmd: run / run_all / run_selected
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
@ -233,8 +244,8 @@ main.c
timeout → SKIP │ │ timeout → SKIP │ │
│ │ │ │
run: IDLE ──┘ │ run: IDLE ──┘ │
run_all: следующий тест в реестре ───┘ run_all / run_selected: следующий тест ───┘
run_all done: protocol_send_summary() done: protocol_send_summary()
``` ```
**Ключевые свойства state machine:** **Ключевые свойства state machine:**
@ -242,10 +253,12 @@ main.c
- `RUNNING` — защита от ложного `is_busy()==false` во время blocking `run()`. - `RUNNING` — защита от ложного `is_busy()==false` во время blocking `run()`.
Пока тест выполняется, новые команды получают `BUSY`. Пока тест выполняется, новые команды получают `BUSY`.
- `test_runner_wait_confirm()` — вызывается из `run()` интерактивных тестов - `test_runner_wait_confirm()` — вызывается из `run()` интерактивных тестов
(display). Внутри polling loop: `bsp_usb_cdc_poll()` + `cli_process()`. (display, mqs). Внутри polling loop: `bsp_usb_cdc_poll()` + `cli_process()`.
USB-стек остаётся живым, confirm приходит без возврата в главный цикл. USB-стек остаётся живым, confirm приходит без возврата в главный цикл.
- `critical=true` + `FAIL` в `run_all` → все оставшиеся тесты получают - `run_selected` работает по той же машине над маской выбранных тестов
`SKIP` немедленно, `summary.overall = "fail"`. (`g_s_selected[]`); порядок — по реестру, не по порядку в запросе.
- `critical=true` + `FAIL` в `run_all`/`run_selected` → все оставшиеся тесты
получают `SKIP` немедленно, `summary.overall = "fail"`.
--- ---
@ -258,7 +271,7 @@ main.c
| Интерфейс | USB CDC ACM, разъём J2 | | Интерфейс | USB CDC ACM, разъём J2 |
| Кодировка | UTF-8 | | Кодировка | UTF-8 |
| Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` | | Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` |
| Максимальная длина строки | 128 байт включая `\n` | | Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) |
| CR+LF | Принимается (таргет отбрасывает `\r`) | | CR+LF | Принимается (таргет отбрасывает `\r`) |
Нет хэндшейка, нет sequence number, нет подтверждений доставки. Нет хэндшейка, нет sequence number, нет подтверждений доставки.
@ -273,7 +286,7 @@ main.c
│ │ │ │
│ [USB SDP: прошивка загружена] │ │ [USB SDP: прошивка загружена] │
│ [CDC ACM: порт открыт] │ │ [CDC ACM: порт открыт] │
│◄─── {"type":"session_start","fw":"0.1.0",...} │ автоматически │◄─── {"type":"session_start","fw":"0.1.2",...} │ автоматически
│ │ │ │
│──── {"type":"cmd","cmd":"ping"} ─────────────►│ │──── {"type":"cmd","cmd":"ping"} ─────────────►│
│◄─── {"type":"pong"} │ │◄─── {"type":"pong"} │
@ -290,7 +303,8 @@ main.c
``` ```
`session_start` отправляется **автоматически** при каждом старте, до получения `session_start` отправляется **автоматически** при каждом старте, до получения
первой команды. Хост должен быть готов принять его сразу после открытия порта. первой команды. Хост должен быть готов принять его сразу после открытия порта
(либо не полагаться на него — фикстуры HIL проверяют живость через `ping`).
--- ---
@ -310,7 +324,7 @@ main.c
```json ```json
→ {"type":"cmd","cmd":"run","id":"sdram"} → {"type":"cmd","cmd":"run","id":"sdram"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true} ← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""} ← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
``` ```
Если `id` не найден: Если `id` не найден:
@ -324,13 +338,58 @@ main.c
```json ```json
→ {"type":"cmd","cmd":"run_all"} → {"type":"cmd","cmd":"run_all"}
← {"type":"test_begin","id":"sdram",...} ← {"type":"test_begin","id":"sdram",...}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""} ← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
← {"type":"test_begin","id":"qspi",...} ← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi","status":"pass","ms":88,"detail":""} ← {"type":"test_result","id":"qspi","status":"pass","ms":86,"detail":""}
← ... (остальные тесты) ... ← ... (остальные тесты) ...
← {"type":"summary","passed":7,"failed":0,"skipped":1,"overall":"pass"} ← {"type":"summary","passed":7,"failed":0,"skipped":1,"overall":"pass"}
``` ```
#### `run_selected` — запуск подмножества тестов
```json
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","opto"]}
← {"type":"test_begin","id":"sdram",...}
← {"type":"test_result","id":"sdram","status":"pass",...}
← {"type":"test_begin","id":"opto",...}
← {"type":"test_result","id":"opto","status":"pass",...}
← {"type":"summary","passed":2,"failed":0,"skipped":0,"overall":"pass"}
```
Порядок выполнения — по реестру таргета, не по порядку в запросе. Если хотя
бы один ID не найден — вся команда отклоняется (`UNKNOWN_TEST`), не запускается
ничего.
#### `list_tests` — получить реестр тестов
```json
→ {"type":"cmd","cmd":"list_tests"}
← {"type":"test_list","tests":[
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
... остальные ...
]}
```
Хост (TUI) использует ответ для динамического построения списка тестов;
`requires_hil=true` тесты недоступны при отсутствии M5StampPLC.
#### `get_uid` — прочитать UID чипа
```json
→ {"type":"cmd","cmd":"get_uid"}
← {"type":"uid_response","uid":"A1B2C3D4E5F60011"}
```
`uid` — 8 байт (`BSP_PROV_UID_LEN`) big-endian, 16 hex-символов без
разделителей. При ошибке чтения: `{"ok":false,"error":"UID_READ_ERR"}`.
#### `get_version` — прочитать версию прошивки
```json
→ {"type":"cmd","cmd":"get_version"}
← {"type":"version_response","fw":"0.1.2"}
```
#### `confirm` — ответ оператора на интерактивный шаг #### `confirm` — ответ оператора на интерактивный шаг
```json ```json
@ -347,35 +406,19 @@ main.c
#### `session_start` #### `session_start`
```json ```json
{ { "type":"session_start", "fw":"0.1.2", "target":"IMXRT1052", "uptime_ms":0 }
"type": "session_start",
"fw": "0.1.0",
"target": "IMXRT1052",
"uptime_ms": 0
}
``` ```
#### `test_begin` #### `test_begin`
```json ```json
{ { "type":"test_begin", "id":"sdram", "name":"SDRAM 32 MB", "critical":true }
"type": "test_begin",
"id": "sdram",
"name": "SDRAM 32 MB",
"critical": true
}
``` ```
#### `test_result` #### `test_result`
```json ```json
{ { "type":"test_result", "id":"sdram", "status":"pass", "ms":15304, "detail":"" }
"type": "test_result",
"id": "sdram",
"status": "pass",
"ms": 312,
"detail": ""
}
``` ```
| `status` | Смысл | | `status` | Смысл |
@ -384,37 +427,39 @@ main.c
| `"fail"` | Тест провален; `detail` содержит описание (до 95 символов) | | `"fail"` | Тест провален; `detail` содержит описание (до 95 символов) |
| `"skip"` | Пропущен: нет оборудования, таймаут, отказ оператора, critical fail выше | | `"skip"` | Пропущен: нет оборудования, таймаут, отказ оператора, critical fail выше |
Примеры `detail`: `"addr=0x80001000 expected=0xA5 got=0x00"`, `"JEDEC ID mismatch"`. Примеры `detail`: `"addr=0x80200001 exp=0x02 got=0xFF"`, `"JEDEC: mfr=0xFF exp=0xEF"`.
#### `test_list`
Ответ на `list_tests` — массив дескрипторов (`id`, `name`, `critical`,
`requires_hil`).
#### `confirm_request` #### `confirm_request`
```json ```json
{ { "type":"confirm_request", "id":"display_red", "prompt":"Screen is solid red?", "timeout_ms":15000 }
"type": "confirm_request",
"id": "display_red",
"prompt": "Экран залит красным цветом?",
"timeout_ms": 15000
}
``` ```
Хост отображает `prompt` оператору. Авторитетный таймаут — на таргете. Хост отображает `prompt` оператору. Авторитетный таймаут — на таргете.
Хост может дублировать countdown для UX. Хост может дублировать countdown для UX.
#### `uid_response` / `version_response`
Ответы на `get_uid` / `get_version` (см. соответствующие команды выше).
#### `summary` #### `summary`
```json ```json
{ { "type":"summary", "passed":6, "failed":1, "skipped":0, "overall":"fail" }
"type": "summary",
"passed": 6,
"failed": 1,
"skipped": 0,
"overall": "fail"
}
``` ```
`"overall":"fail"` — если хотя бы один `critical` тест провален. `"overall":"fail"` — если хотя бы один `critical` тест провален.
`"overall":"pass"` — все `critical` тесты прошли (non-critical могут fail). `"overall":"pass"` — все `critical` тесты прошли (non-critical могут fail).
#### `pong`
Ответ на `ping`: `{"type":"pong"}`.
--- ---
### Ошибки протокола ### Ошибки протокола
@ -425,43 +470,60 @@ main.c
← {"ok":false,"error":"UNKNOWN_TEST"} — "id" не найден в реестре ← {"ok":false,"error":"UNKNOWN_TEST"} — "id" не найден в реестре
← {"ok":false,"error":"LINE_TOO_LONG"} — строка превысила 128 байт ← {"ok":false,"error":"LINE_TOO_LONG"} — строка превысила 128 байт
← {"ok":false,"error":"BUSY"} — таргет выполняет тест ← {"ok":false,"error":"BUSY"} — таргет выполняет тест
← {"ok":false,"error":"UID_READ_ERR"} — bsp_prov_read_uid() вернул ошибку
``` ```
--- ---
### Интерактивные тесты ### Интерактивные тесты
#### uSD — вставить карту #### microSD — вставить карту (pre-confirm)
```bash ```bash
← {"type":"confirm_request","id":"usd_insert","prompt":"Вставьте microSD","timeout_ms":30000} ← {"type":"confirm_request","id":"usd","prompt":"Insert microSD card and press OK","timeout_ms":30000}
→ {"type":"confirm","id":"usd_insert","confirmed":true} → {"type":"confirm","id":"usd","confirmed":true}
← {"type":"test_begin","id":"usd",...} ← {"type":"test_begin","id":"usd",...}
← {"type":"test_result","id":"usd","status":"pass","ms":541,"detail":""} ← {"type":"test_result","id":"usd","status":"pass","ms":874,"detail":""}
``` ```
Если оператор отказался или таймаут: confirm id для pre-confirm равен id теста (`usd`) — механизм `pre_confirm_prompt`
использует `mod->id`. Отказ или таймаут 30 с`SKIP`.
```bash #### Display RGB888 — подтвердить цвета и ротацию (in-run confirm)
← {"type":"test_result","id":"usd","status":"skip","ms":0,"detail":"operator skipped"}
```
#### Display RGB888 — подтвердить цвета Шесть шагов: Red → Green → Blue → White, затем два ротационных
(диагностика непропаянных LR/UD пинов). Тест прерывается на **первом**
Четыре шага R/G/B/W. Итог — AND всех подтверждений. неподтверждённом шаге (короткое замыкание, не сбор всех ответов).
```bash ```bash
← {"type":"test_begin","id":"display",...} ← {"type":"test_begin","id":"display",...}
← {"type":"confirm_request","id":"display_red","prompt":"Экран красный?","timeout_ms":15000} ← {"type":"confirm_request","id":"display_red","prompt":"Screen is solid red?","timeout_ms":15000}
→ {"type":"confirm","id":"display_red","confirmed":true} → {"type":"confirm","id":"display_red","confirmed":true}
← {"type":"confirm_request","id":"display_green",...} ← {"type":"confirm_request","id":"display_green",...}
→ {"type":"confirm","id":"display_green","confirmed":true} → {"type":"confirm","id":"display_green","confirmed":true}
← {"type":"confirm_request","id":"display_blue",...} ← {"type":"confirm_request","id":"display_blue",...}
→ {"type":"confirm","id":"display_blue","confirmed":true} → {"type":"confirm","id":"display_blue","confirmed":true}
← {"type":"confirm_request","id":"display_white",...} ← {"type":"confirm_request","id":"display_white",...}
→ {"type":"confirm","id":"display_white","confirmed":false} → {"type":"confirm","id":"display_white","confirmed":true}
← {"type":"test_result","id":"display","status":"fail","ms":22103, ← {"type":"confirm_request","id":"display_rot0","prompt":"Screen: left RED, right BLUE?","timeout_ms":15000}
"detail":"display_white not confirmed"} → {"type":"confirm","id":"display_rot0","confirmed":true}
← {"type":"confirm_request","id":"display_rot_base","prompt":"Left RED and right BLUE swapped sides?","timeout_ms":15000}
→ {"type":"confirm","id":"display_rot_base","confirmed":true}
← {"type":"test_result","id":"display","status":"pass",...}
```
При отказе/таймауте: `status:"fail"`, `detail:"<id> not confirmed"`.
#### MQS Audio — подтвердить слышимость тона (in-run confirm)
Таргет ~4 с играет мелодию (A4, затем E5) через MQS + LM4875M, затем запрашивает
подтверждение:
```bash
← {"type":"test_begin","id":"mqs",...}
← {"type":"confirm_request","id":"mqs_tone","prompt":"Do you hear a tone?","timeout_ms":15000}
→ {"type":"confirm","id":"mqs_tone","confirmed":true}
← {"type":"test_result","id":"mqs","status":"pass",...}
``` ```
#### Кнопки — нажать физически #### Кнопки — нажать физически
@ -472,36 +534,44 @@ main.c
```bash ```bash
← {"type":"test_begin","id":"buttons",...} ← {"type":"test_begin","id":"buttons",...}
← {"type":"confirm_request","id":"btn1_press","prompt":"Нажмите Test_But_1","timeout_ms":10000} ← {"type":"confirm_request","id":"btn1_press","prompt":"Press Test_But_1","timeout_ms":10000}
[таргет ждёт bsp_button — без JSON confirm от хоста] [таргет ждёт bsp_button — без JSON confirm от хоста]
← {"type":"confirm_request","id":"btn2_press","prompt":"Нажмите Test_But_2","timeout_ms":10000} ← {"type":"confirm_request","id":"btn2_press","prompt":"Press Test_But_2","timeout_ms":10000}
← {"type":"test_result","id":"buttons","status":"pass","ms":3821,"detail":""} ← {"type":"test_result","id":"buttons","status":"pass",...}
``` ```
Таймаут 10 с`SKIP` (не FAIL).
> Полные потоки всех тестов (SDRAM/QSPI/opto/CAN, коды `detail`, HIL pytest) —
> в справочнике по тестированию `README_TESTING.md`.
--- ---
## Матрица тестов ## Матрица тестов
| ID | Название | Тип | Critical | M5 HIL | Confirm | Порядок — как в реестре `k_registry[]` (`test_runner.c`).
| ---------- | ---------------- | ---------------- | -------- | ------ | ------------- |
| `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ | | № | ID | Название | Тип | Critical | M5 HIL | Confirm |
| `qspi` | QSPI Flash 8 MB | self | ✅ | ❌ | ❌ | | --- | --------- | ------------------ | ---------------------------- | -------- | ------ | --------------- |
| `usd` | uSD SDIO | self+interactive | ❌ | ❌ | ✅ pre_confirm | | 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ |
| `display` | Display RGB888 | interactive | ❌ | ❌ | ✅ 4×в run() | | 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ |
| `buttons` | Test_But_1/2 | interactive | ❌ | ❌ | prompt only | | 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm |
| `can` | CAN loopback | HIL | ❌ | ✅ | ❌ | | 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() |
| `uart_ttl` | UART TTL | HIL | ❌ | ✅ | ❌ | | 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only |
| `uart_iso` | UART ISO / RS_RX | HIL | ❌ | ✅ | ❌ | | 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) |
| `opto` | Opto EXT_IN1/IN2 | HIL | ❌ | ✅ | ❌ | | 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) |
| 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() |
**Типы confirm:** **Типы confirm:**
- **pre_confirm** — test_runner отправляет `confirm_request` до вызова `run()`, - **pre_confirm**`test_runner` отправляет `confirm_request` до вызова `run()`,
ждёт JSON-ответ через state machine (асинхронно). ждёт JSON-ответ через state machine (асинхронно). id = id теста.
- **в run()** — тест сам вызывает `test_runner_wait_confirm()` изнутри `run()`, - **в run()** — тест сам вызывает `test_runner_wait_confirm()` изнутри `run()`,
блокируется до ответа (синхронно). блокируется до ответа (синхронно).
- **prompt only**`protocol_send_confirm_request()` отправляется как UI-подсказка, - **prompt only**`protocol_send_confirm_request()` как UI-подсказка,
хост не отвечает JSON, таргет ждёт физического события. хост не отвечает JSON, таргет ждёт физического события.
- **авто (HIL)** — confirm генерирует не оператор, а хост-оркестратор, командуя
M5StampPLC (см. `README_TESTING.md`).
--- ---
@ -540,7 +610,7 @@ static test_result_t test_foo_run(void)
return result; return result;
} }
const test_module_t k_test_foo = { const test_module_t K_TEST_FOO = {
.id = "foo", /* короткий ASCII-ключ */ .id = "foo", /* короткий ASCII-ключ */
.name = "Foo Peripheral", .name = "Foo Peripheral",
.critical = false, /* true → run_all стопится при fail */ .critical = false, /* true → run_all стопится при fail */
@ -558,15 +628,19 @@ const test_module_t k_test_foo = {
```c ```c
/* Forward declarations */ /* Forward declarations */
extern const test_module_t k_test_sdram; extern const test_module_t K_TEST_SDRAM;
extern const test_module_t k_test_foo; /* ← добавить */ extern const test_module_t K_TEST_FOO; /* ← добавить */
static const test_module_t *const k_registry[] = { static const test_module_t *const k_registry[] = {
&k_test_sdram, &K_TEST_SDRAM,
&k_test_foo, /* ← добавить */ ...
&K_TEST_FOO, /* ← добавить */
}; };
``` ```
> При росте реестра выше `TEST_REGISTRY_MAX_SIZE` (`test_module.h`) сборка
> упадёт на `_Static_assert` в `test_runner.c` — увеличить константу.
### Шаг 3 — Добавить в CMakeLists.txt ### Шаг 3 — Добавить в CMakeLists.txt
**Файл:** `firmware/test/CMakeLists.txt` **Файл:** `firmware/test/CMakeLists.txt`
@ -578,7 +652,6 @@ add_executable(
src/cli.c src/cli.c
src/protocol.c src/protocol.c
src/test_runner.c src/test_runner.c
src/tests/test_sdram.c
src/tests/test_foo.c # ← добавить src/tests/test_foo.c # ← добавить
... ...
) )
@ -592,24 +665,18 @@ target_link_libraries(
### Шаг 4 — Обновить матрицу тестов ### Шаг 4 — Обновить матрицу тестов
Добавить строку в таблицу в этом README. Добавить строку в таблицу в этом README (и, если есть протокольный поток —
в `README_TESTING.md`).
### Шаблоны для разных типов тестов ### Шаблоны для разных типов тестов
#### Self-тест с инициализацией #### Self-тест с инициализацией
```c ```c
static void test_foo_init(void) static void test_foo_init(void) { bsp_foo_init(); }
{ static void test_foo_deinit(void) { bsp_foo_deinit(); }
bsp_foo_init();
}
static void test_foo_deinit(void) const test_module_t K_TEST_FOO = {
{
bsp_foo_deinit();
}
const test_module_t k_test_foo = {
.id = "foo", .id = "foo",
.init = test_foo_init, .init = test_foo_init,
.run = test_foo_run, .run = test_foo_run,
@ -622,7 +689,6 @@ const test_module_t k_test_foo = {
```c ```c
#include "test_runner.h" /* test_runner_wait_confirm() */ #include "test_runner.h" /* test_runner_wait_confirm() */
#include "protocol.h" /* protocol_send_confirm_request() */
static test_result_t test_foo_run(void) static test_result_t test_foo_run(void)
{ {
@ -650,13 +716,13 @@ static test_result_t test_foo_run(void)
#### Тест с pre_confirm (вставить карту, подключить кабель) #### Тест с pre_confirm (вставить карту, подключить кабель)
```c ```c
const test_module_t k_test_foo = { const test_module_t K_TEST_FOO = {
.id = "foo", .id = "foo",
.pre_confirm_prompt = "Подключите кабель к разъёму X и нажмите OK", .pre_confirm_prompt = "Подключите кабель к разъёму X и нажмите OK",
.run = test_foo_run, .run = test_foo_run,
... ...
}; };
/* test_runner сам отправит confirm_request перед вызовом run() */ /* test_runner сам отправит confirm_request (id = "foo") перед вызовом run() */
``` ```
--- ---
@ -727,34 +793,34 @@ void test_run_all_critical_fail_skips_remaining(void)
| `cli_process()` | `FAKE_VOID_FUNC(cli_process)` | | `cli_process()` | `FAKE_VOID_FUNC(cli_process)` |
| `protocol_send_test_result()` | custom_fake — копируем `*p_result` по значению | | `protocol_send_test_result()` | custom_fake — копируем `*p_result` по значению |
> **Ловушка dangling pointer:** `protocol_send_test_result` получает указатель
> на стековую переменную внутри `execute_test()`. После возврата указатель
> инвалиден — используй `custom_fake` с `s_captured = *p_result` пока стек жив.
--- ---
## Версионирование ## Версионирование
`FIRMWARE_TEST_VERSION` в `protocol.h` — единственная точка правды о версии. Текущая версия ПО задается с помощью `project(firmware_test VERSION X.Y.Z)`
Поле `"fw"` в `session_start` несёт эту строку. в `firmware/test/CMakeLists.txt`. CMake прокидывает её через
`configure_file(src/version.h.in → generated/version.h)`, откуда `protocol.h`
берёт `FIRMWARE_TEST_VERSION_STR`:
При несовместимых изменениях протокола (новое обязательное поле, изменение ```
семантики) — bumping версии + обновление этого документа. CMakeLists.txt: project(firmware_test VERSION 0.1.2)
│ configure_file(@ONLY)
generated/version.h: FIRMWARE_TEST_VERSION_STR = "0.1.2"
protocol.h: #define FIRMWARE_TEST_VERSION FIRMWARE_TEST_VERSION_STR
session_start / version_response: "fw":"0.1.2"
```
Хост должен сверять `"fw"` при подключении и предупреждать оператора при `version.h` генерируется, **не** редактируется вручную. Менять версию —
несовпадении ожидаемой версии. только в `CMakeLists.txt`.
--- Хост может запросить версию явно (`get_version` → `version_response`) или
прочитать её из `session_start`, и предупредить оператора при несовпадении
с ожидаемой. При несовместимых изменениях протокола (новое обязательное поле,
смена семантики) — bump версии + обновление этого документа и `README_TESTING.md`.
## Архитектурные решения (закрыты) ##
> Не пересматривать без явного запроса.
| Решение | Обоснование |
| -------------------------------------------- | ------------------------------------------------------------------ |
| Единственный транспорт — USB CDC ACM | HIL ELF-прошивки используют отдельный канал (UART + bsp_uart_host) |
| Парсинг JSON — strstr без cJSON | Схема фиксирована, cJSON избыточен |
| SDRAM тест — через firmware_test, не HIL ELF | Тест идёт командами по USB CDC |
| IR и RTC — не реализуются | Вне scope рекламационной диагностики |
| Тесты атомарны | Инженер сам решает что проверять |
| Тест-логика на таргете | Хост — тонкий клиент, нет дублирования логики |

View file

@ -191,7 +191,7 @@ static size_t parse_string_array(const char *p_array_start, char (*p_out_bufs)[T
{ {
return 0U; return 0U;
} }
p++; /* пропустить '[' */ p++;
size_t count = 0U; size_t count = 0U;
while (count < max_items) while (count < max_items)
@ -285,8 +285,6 @@ static void handle_cmd_run_selected(const char *p_line)
/** /**
* @brief Обработать сообщение {"type":"cmd",...}. * @brief Обработать сообщение {"type":"cmd",...}.
*
* Команды: ping pong, run_all test_runner, run test_runner.
*/ */
static void handle_cmd(const char *p_line) static void handle_cmd(const char *p_line)
{ {

View file

@ -115,7 +115,6 @@ void protocol_send_confirm_request(const confirm_params_t *p_params)
void protocol_send_test_list(const test_module_t *const *p_pp_registry, size_t count) void protocol_send_test_list(const test_module_t *const *p_pp_registry, size_t count)
{ {
/* Заголовок массива */
cli_send("{\"type\":\"test_list\",\"tests\":["); cli_send("{\"type\":\"test_list\",\"tests\":[");
for (size_t i = 0U; i < count; i++) for (size_t i = 0U; i < count; i++)

View file

@ -91,7 +91,7 @@ static volatile bool g_s_confirm_value;
static char g_s_pending_confirm_id[RUNNER_CONFIRM_ID_SIZE]; static char g_s_pending_confirm_id[RUNNER_CONFIRM_ID_SIZE];
static uint32_t g_s_confirm_deadline_ms; static uint32_t g_s_confirm_deadline_ms;
/* Счётчики итога (используются только в RUNNER_MODE_ALL) */ /* Счётчики итога (ALL и SELECTED; в SINGLE не используются) */
static uint8_t g_s_passed; static uint8_t g_s_passed;
static uint8_t g_s_failed; static uint8_t g_s_failed;
static uint8_t g_s_skipped; static uint8_t g_s_skipped;

View file

@ -1,10 +1,15 @@
# firmware_test — Руководство по тестированию # firmware_test — Руководство по тестированию
Тестовая прошивка входного контроля платы MIMXRT1052CVJ5B. Тестовая прошивка входного контроля платы TFT индикатора.
Транспорт: USB CDC ACM (J2). Протокол: JSON-lines v2, один JSON-объект на строку. Транспорт: USB CDC ACM (J2). Протокол: JSON-lines v2, один JSON-объект на строку.
Загружается в RAM через BootROM USB SDP — без предварительной прошивки загрузчика. Загружается в RAM через BootROM USB SDP — без предварительной прошивки загрузчика.
> Версия прошивки: `0.1.4` (`FIRMWARE_TEST_VERSION` в `protocol.h`) > Версия прошивки: `0.1.2` — из `project(firmware_test VERSION X.Y.Z)` в
> `CMakeLists.txt` (через `version.h.in``version.h`
> `FIRMWARE_TEST_VERSION_STR` в `protocol.h`).
>
> Архитектура, сборка, добавление тестов и host unit-тесты — в основном
> `README.md`. Этот документ — протокольные потоки по каждому тесту.
--- ---
@ -17,8 +22,10 @@
| `cmd` | `{"type":"cmd","cmd":"ping"}` | Проверка канала | | `cmd` | `{"type":"cmd","cmd":"ping"}` | Проверка канала |
| `cmd` | `{"type":"cmd","cmd":"run","id":"sdram"}` | Запустить один тест по ID | | `cmd` | `{"type":"cmd","cmd":"run","id":"sdram"}` | Запустить один тест по ID |
| `cmd` | `{"type":"cmd","cmd":"run_all"}` | Запустить все тесты реестра | | `cmd` | `{"type":"cmd","cmd":"run_all"}` | Запустить все тесты реестра |
| `cmd` | `{"type":"cmd","cmd":"list_tests"}` | Получить реестр тестов с метаданными |
| `cmd` | `{"type":"cmd","cmd":"run_selected","tests":["sdram","qspi"]}` | Запустить подмножество тестов | | `cmd` | `{"type":"cmd","cmd":"run_selected","tests":["sdram","qspi"]}` | Запустить подмножество тестов |
| `cmd` | `{"type":"cmd","cmd":"list_tests"}` | Получить реестр тестов с метаданными |
| `cmd` | `{"type":"cmd","cmd":"get_uid"}` | Прочитать UID чипа (OCOTP) |
| `cmd` | `{"type":"cmd","cmd":"get_version"}` | Прочитать версию прошивки |
| `confirm` | `{"type":"confirm","id":"usd","confirmed":true}` | Ответ оператора на запрос | | `confirm` | `{"type":"confirm","id":"usd","confirmed":true}` | Ответ оператора на запрос |
### Исходящие события (target → host) ### Исходящие события (target → host)
@ -30,8 +37,10 @@
| `test_begin` | `id`, `name`, `critical` | Тест запущен | | `test_begin` | `id`, `name`, `critical` | Тест запущен |
| `test_result` | `id`, `status`, `ms`, `detail` | Результат теста | | `test_result` | `id`, `status`, `ms`, `detail` | Результат теста |
| `confirm_request` | `id`, `prompt`, `timeout_ms` | Запрос оператору | | `confirm_request` | `id`, `prompt`, `timeout_ms` | Запрос оператору |
| `progress` | `test`, `step`, `status` | Прогресс внутри теста | | `progress` | `test`, `step`, `status` | Прогресс внутри теста (usd) |
| `summary` | `passed`, `failed`, `skipped`, `overall` | Итог `run_all` / `run_selected` | | `summary` | `passed`, `failed`, `skipped`, `overall` | Итог `run_all` / `run_selected` |
| `uid_response` | `uid` (16 hex, 8 байт big-endian) | Ответ на `get_uid` |
| `version_response` | `fw` (`X.Y.Z`) | Ответ на `get_version` |
| `pong` | — | Ответ на `ping` | | `pong` | — | Ответ на `ping` |
| `{"ok":false,"error":"..."}` | `error` | Ошибка протокола | | `{"ok":false,"error":"..."}` | `error` | Ошибка протокола |
@ -40,11 +49,13 @@
**Коды ошибок в `error`:** **Коды ошибок в `error`:**
| Код | Причина | | Код | Причина |
| -------------- | --------------------------------------------------------------- | | --------------- | --------------------------------------------------------------- |
| `BUSY` | Предыдущий тест ещё выполняется | | `BUSY` | Предыдущий тест ещё выполняется |
| `UNKNOWN_TEST` | ID теста не найден в реестре | | `UNKNOWN_TEST` | ID теста не найден в реестре |
| `PARSE_ERR` | Не удалось разобрать JSON (нет поля `type`, `cmd`, `id` и т.д.) | | `PARSE_ERR` | Не удалось разобрать JSON (нет поля `type`, `cmd`, `id` и т.д.) |
| `UNKNOWN_CMD` | Неизвестный тип сообщения или команда | | `UNKNOWN_CMD` | Неизвестный тип сообщения или команда |
| `LINE_TOO_LONG` | Входящая строка превысила 128 байт (`CLI_LINE_BUF_SIZE`) |
| `UID_READ_ERR` | `bsp_prov_read_uid()` вернул ошибку (ответ на `get_uid`) |
--- ---
@ -54,7 +65,7 @@
После подключения немедленно отправляет `session_start`: После подключения немедленно отправляет `session_start`:
```bash ```bash
← {"type":"session_start","fw":"0.1.4","target":"IMXRT1052","uptime_ms":1108} ← {"type":"session_start","fw":"0.1.2","target":"IMXRT1052","uptime_ms":1108}
``` ```
### Проверка канала (ping) ### Проверка канала (ping)
@ -64,6 +75,19 @@
← {"type":"pong"} ← {"type":"pong"}
``` ```
### Идентификация платы (get_uid / get_version)
```bash
→ {"type":"cmd","cmd":"get_uid"}
← {"type":"uid_response","uid":"A1B2C3D4E5F60011"}
→ {"type":"cmd","cmd":"get_version"}
← {"type":"version_response","fw":"0.1.2"}
```
`uid` — 8 байт (`BSP_PROV_UID_LEN`) big-endian, 16 hex-символов без разделителей.
При ошибке чтения UID: `{"ok":false,"error":"UID_READ_ERR"}`.
--- ---
## SDRAM — контроль оперативной памяти ## SDRAM — контроль оперативной памяти
@ -179,7 +203,7 @@ sequenceDiagram
| Время выполнения | < 1 с после вставки карты | | Время выполнения | < 1 с после вставки карты |
Оператор вставляет карту по запросу. Тест запускается только после подтверждения. Оператор вставляет карту по запросу. Тест запускается только после подтверждения.
Отказ или таймаут 30 с`SKIP`. Отказ или таймаут 30 с`SKIP`. confirm id для pre-confirm равен id теста (`usd`).
Пять шагов с `progress`-событиями: card detect → mount → write 4 KB → read/compare → unmount. Пять шагов с `progress`-событиями: card detect → mount → write 4 KB → read/compare → unmount.
@ -246,7 +270,8 @@ sequenceDiagram
**pre-confirm отсутствует** — `test_begin` отправляется сразу после `run`. **pre-confirm отсутствует** — `test_begin` отправляется сразу после `run`.
Два этапа, каждый шаг требует подтверждения оператора (таймаут 15 с → FAIL): Два этапа, каждый шаг требует подтверждения оператора (таймаут 15 с → FAIL);
тест прерывается на первом неподтверждённом шаге:
- **Этап 1 (все дисплеи):** Red → Green → Blue → White - **Этап 1 (все дисплеи):** Red → Green → Blue → White
- **Этап 2 (TFT7/8/10):** паттерн Red/Blue + горизонтальный флип — диагностика непропаянных LR/UD пинов - **Этап 2 (TFT7/8/10):** паттерн Red/Blue + горизонтальный флип — диагностика непропаянных LR/UD пинов
@ -379,8 +404,9 @@ sequenceDiagram
| Тип | Interactive (in-run confirm) | | Тип | Interactive (in-run confirm) |
| Время выполнения | ~4 с воспроизведение + до 15 с на confirm | | Время выполнения | ~4 с воспроизведение + до 15 с на confirm |
Тест воспроизводит мелодию (~4 с: нота A4 затем E5) через MQS-выход Тест воспроизводит мелодию (~4 с: нота A4 затем E5, стерео PCM16 44100 Гц)
(`MQS_RIGHT`, `GPIO_AD_B0_04`) и усилитель LM4875M. через MQS-выход (`MQS_RIGHT`, `GPIO_AD_B0_04`) и усилитель LM4875M.
Буфер — статический в некэшируемой секции (OCRAM NonCacheable), L == R.
Оператор подтверждает слышимость тона. Оператор подтверждает слышимость тона.
```mermaid ```mermaid
@ -422,147 +448,6 @@ sequenceDiagram
--- ---
## Запуск всего набора (run_all)
Тесты запускаются строго в порядке реестра. При провале критичного теста
(`sdram` или `qspi`) все последующие тесты получают `SKIP` с `detail:"critical test failed"`.
```bash
→ {"type":"cmd","cmd":"run_all"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
← {"type":"test_begin","id":"qspi","name":"QSPI Flash W25Qxx","critical":true}
← {"type":"test_result","id":"qspi","status":"pass","ms":86,"detail":""}
← {"type":"confirm_request","id":"usd","prompt":"Insert microSD card and press OK","timeout_ms":30000}
... оператор вставляет карту и подтверждает ...
← {"type":"test_begin","id":"usd","name":"microSD (SDIO)","critical":false}
...progress events...
← {"type":"test_result","id":"usd","status":"pass","ms":874,"detail":""}
← {"type":"test_begin","id":"display","name":"TFT Display RGB888","critical":false}
...confirm цикл 6 шагов...
← {"type":"test_result","id":"display","status":"pass","ms":42310,"detail":""}
← {"type":"test_begin","id":"buttons","name":"Test Buttons","critical":false}
← {"type":"confirm_request","id":"btn1_press","prompt":"Press Test_But_1","timeout_ms":10000}
...
← {"type":"test_result","id":"buttons","status":"pass","ms":6200,"detail":""}
← {"type":"test_begin","id":"mqs","name":"MQS Audio Out","critical":false}
← {"type":"confirm_request","id":"mqs_tone","prompt":"Do you hear a tone?","timeout_ms":15000}
... оператор слышит и подтверждает ...
← {"type":"test_result","id":"mqs","status":"pass","ms":19240,"detail":""}
← {"type":"test_begin","id":"opto","name":"Opto Inputs","critical":false}
...confirm цикл 6 HIL шагов...
← {"type":"test_result","id":"opto","status":"pass","ms":3210,"detail":""}
← {"type":"test_begin","id":"can","name":"CAN loopback","critical":false}
...confirm цикл 2 HIL шагов...
← {"type":"test_result","id":"can","status":"pass","ms":1240,"detail":""}
← {"type":"summary","passed":8,"failed":0,"skipped":0,"overall":"pass"}
```
**SKIP-каскад при critical fail:**
```bash
← {"type":"test_result","id":"sdram","status":"fail","ms":1203,"detail":"addr=0x80200001..."}
← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi","status":"skip","ms":0,"detail":"critical test failed"}
← {"type":"test_begin","id":"usd",...}
← {"type":"test_result","id":"usd","status":"skip","ms":0,"detail":"critical test failed"}
...
← {"type":"summary","passed":0,"failed":1,"skipped":7,"overall":"fail"}
```
---
## Реестр тестов — порядок выполнения
| № | ID | Название | Critical | HIL | Тип |
| --- | --------- | ------------------ | -------- | --- | ---------------------------- |
| 1 | `sdram` | SDRAM 32 MB | ✅ | ❌ | Self-test |
| 2 | `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | Self-test |
| 3 | `usd` | microSD (SDIO) | ❌ | ❌ | Interactive (pre-confirm) |
| 4 | `display` | TFT Display RGB888 | ❌ | ❌ | Interactive (in-run confirm) |
| 5 | `buttons` | Test Buttons | ❌ | ❌ | Interactive (physical) |
| 6 | `mqs` | MQS Audio Out | ❌ | ❌ | Interactive (in-run confirm) |
| 7 | `opto` | Opto Inputs | ❌ | ✅ | HIL (M5StampPLC RLY2/3/4) |
| 8 | `can` | CAN loopback | ❌ | ✅ | HIL (M5StampPLC CAN) |
---
## Диагностика — строки detail
| Тест | Значение `detail` | Диагноз |
| --------- | ----------------------------------------------- | ------------------------------------------- |
| `sdram` | `addr=0x... exp=0x.. got=0x..` | Сбой ячейки по адресу |
| `sdram` | `SEMC not ready — DCD failed?` | DCD не инициализировал SEMC |
| `qspi` | `JEDEC: mfr=0xFF exp=0xEF` | Чип не отвечает / не пропаян |
| `qspi` | `JEDEC: unknown cap=0x..` | Неизвестный тип чипа |
| `qspi` | `erase verify failed at 0x...` | Сектор не стирается |
| `qspi` | `rw mismatch at 0x... exp=0x.. got=0x..` | Ошибка записи или чтения |
| `qspi` | `addr alias: 0x... mirrors 0x... (3-byte wrap)` | Dedicated 4-byte opcodes не работают |
| `usd` | `no card detected` | Карта не вставлена в слот |
| `usd` | `mount failed: <N>` | `f_mount()` вернул FRESULT N |
| `usd` | `write failed: <N>` | `f_write()` вернул FRESULT N |
| `usd` | `compare failed at offset <N>` | Данные после чтения не совпадают |
| `display` | `display init failed` | `bsp_display_init()` вернул ошибку |
| `display` | `<id> not confirmed` | Оператор не подтвердил / истёк таймаут 15 с |
| `buttons` | `btn1_press timeout` | Test_But_1 не нажата за 10 с |
| `buttons` | `btn2_press timeout` | Test_But_2 не нажата за 10 с |
| `mqs` | `mqs play error` | SAI3/DMA не запустился |
| `mqs` | `operator: no sound` | Нет звука / усилитель не работает |
| `opto` | `<id> mismatch: expected ACTIVE got INACTIVE` | Реле не переключило оптовход |
| `can` | `can_rx_ready: no frame received` | M5 не отправил фрейм / CAN не подключён |
| `can` | `rx id mismatch: expected 0x100 got 0x...` | Неверный ID принятого фрейма |
| `can` | `rx data mismatch: got XX XX XX XX` | Данные фрейма не совпадают |
| `can` | `tx failed: bsp_can_send returned <N>` | TX timeout или шина недоступна |
| `can` | `can_tx_verify: M5 did not confirm tx frame` | M5 не получил фрейм от таргета |
| любой | `confirm timeout` | pre-confirm не получен за 30 с |
| любой | `operator declined` | Получен `"confirmed":false` |
| любой | `critical test failed` | Предшествующий критичный тест провалился |
---
## list_tests — получить реестр тестов
```bash
→ {"type":"cmd","cmd":"list_tests"}
← {"type":"test_list","tests":[
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
{"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false},
{"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false},
{"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false},
{"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false},
{"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false},
{"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true},
{"id":"can","name":"CAN loopback","critical":false,"requires_hil":true}
]}
```
TUI использует этот ответ для динамического построения списка тестов.
HIL-тесты (`requires_hil=true`) недоступны если M5StampPLC не подключён.
---
## run_selected — запустить подмножество тестов
```bash
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","opto"]}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
← {"type":"test_begin","id":"opto","name":"Opto Inputs","critical":false}
...confirm цикл 6 шагов (HIL)...
← {"type":"test_result","id":"opto","status":"pass","ms":3210,"detail":""}
← {"type":"summary","passed":2,"failed":0,"skipped":0,"overall":"pass"}
```
Порядок выполнения — как в реестре таргета, не как в запросе.
Если хотя бы один ID не найден — вся команда отклоняется:
```bash
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","unknown_test"]}
← {"ok":false,"error":"UNKNOWN_TEST"}
```
---
## Оптоизолированные входы (HIL) ## Оптоизолированные входы (HIL)
| Параметр | Значение | | Параметр | Значение |
@ -583,7 +468,8 @@ HIL-тесты (`requires_hil=true`) недоступны если M5StampPLC н
| 6 | `opto_rs_inactive` | RLY2 OFF | `BSP_OPTO_CH_RS == INACTIVE` | | 6 | `opto_rs_inactive` | RLY2 OFF | `BSP_OPTO_CH_RS == INACTIVE` |
HIL confirm полностью автоматический — TUI командует M5 и отправляет confirm HIL confirm полностью автоматический — TUI командует M5 и отправляет confirm
без участия оператора. без участия оператора. После confirm тест выжидает settle (~30 мс, перекрывает
debounce 10 мс) и читает состояние через `bsp_opto_force_read()`.
```bash ```bash
→ {"type":"cmd","cmd":"run","id":"opto"} → {"type":"cmd","cmd":"run","id":"opto"}
@ -625,6 +511,150 @@ HIL confirm полностью автоматический — TUI команд
--- ---
## list_tests — получить реестр тестов
```bash
→ {"type":"cmd","cmd":"list_tests"}
← {"type":"test_list","tests":[
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
{"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false},
{"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false},
{"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false},
{"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false},
{"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true},
{"id":"can","name":"CAN loopback","critical":false,"requires_hil":true},
{"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false}
]}
```
TUI использует этот ответ для динамического построения списка тестов.
HIL-тесты (`requires_hil=true`) недоступны если M5StampPLC не подключён.
Порядок в ответе — порядок реестра `k_registry[]` (см. ниже).
---
## run_selected — запустить подмножество тестов
```bash
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","opto"]}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
← {"type":"test_begin","id":"opto","name":"Opto Inputs","critical":false}
...confirm цикл 6 шагов (HIL)...
← {"type":"test_result","id":"opto","status":"pass","ms":3210,"detail":""}
← {"type":"summary","passed":2,"failed":0,"skipped":0,"overall":"pass"}
```
Порядок выполнения — как в реестре таргета, не как в запросе.
Если хотя бы один ID не найден — вся команда отклоняется:
```bash
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","unknown_test"]}
← {"ok":false,"error":"UNKNOWN_TEST"}
```
---
## Запуск всего набора (run_all)
Тесты запускаются строго в порядке реестра. При провале критичного теста
(`sdram` или `qspi`) все последующие тесты получают `SKIP` с `detail:"critical test failed"`.
```bash
→ {"type":"cmd","cmd":"run_all"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":15304,"detail":""}
← {"type":"test_begin","id":"qspi","name":"QSPI Flash W25Qxx","critical":true}
← {"type":"test_result","id":"qspi","status":"pass","ms":86,"detail":""}
← {"type":"confirm_request","id":"usd","prompt":"Insert microSD card and press OK","timeout_ms":30000}
... оператор вставляет карту и подтверждает ...
← {"type":"test_begin","id":"usd","name":"microSD (SDIO)","critical":false}
...progress events...
← {"type":"test_result","id":"usd","status":"pass","ms":874,"detail":""}
← {"type":"test_begin","id":"display","name":"TFT Display RGB888","critical":false}
...confirm цикл 6 шагов...
← {"type":"test_result","id":"display","status":"pass","ms":42310,"detail":""}
← {"type":"test_begin","id":"buttons","name":"Test Buttons","critical":false}
← {"type":"confirm_request","id":"btn1_press","prompt":"Press Test_But_1","timeout_ms":10000}
...
← {"type":"test_result","id":"buttons","status":"pass","ms":6200,"detail":""}
← {"type":"test_begin","id":"opto","name":"Opto Inputs","critical":false}
...confirm цикл 6 HIL шагов...
← {"type":"test_result","id":"opto","status":"pass","ms":3210,"detail":""}
← {"type":"test_begin","id":"can","name":"CAN loopback","critical":false}
...confirm цикл 2 HIL шагов...
← {"type":"test_result","id":"can","status":"pass","ms":1240,"detail":""}
← {"type":"test_begin","id":"mqs","name":"MQS Audio Out","critical":false}
← {"type":"confirm_request","id":"mqs_tone","prompt":"Do you hear a tone?","timeout_ms":15000}
... оператор слышит и подтверждает ...
← {"type":"test_result","id":"mqs","status":"pass","ms":19240,"detail":""}
← {"type":"summary","passed":8,"failed":0,"skipped":0,"overall":"pass"}
```
**SKIP-каскад при critical fail:**
```bash
← {"type":"test_result","id":"sdram","status":"fail","ms":1203,"detail":"addr=0x80200001..."}
← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi","status":"skip","ms":0,"detail":"critical test failed"}
← {"type":"test_begin","id":"usd",...}
← {"type":"test_result","id":"usd","status":"skip","ms":0,"detail":"critical test failed"}
...
← {"type":"summary","passed":0,"failed":1,"skipped":7,"overall":"fail"}
```
---
## Реестр тестов — порядок выполнения
Порядок соответствует `k_registry[]` в `test_runner.c`.
| № | ID | Название | Critical | HIL | Тип |
| --- | --------- | ------------------ | -------- | --- | ---------------------------- |
| 1 | `sdram` | SDRAM 32 MB | ✅ | ❌ | Self-test |
| 2 | `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | Self-test |
| 3 | `usd` | microSD (SDIO) | ❌ | ❌ | Interactive (pre-confirm) |
| 4 | `display` | TFT Display RGB888 | ❌ | ❌ | Interactive (in-run confirm) |
| 5 | `buttons` | Test Buttons | ❌ | ❌ | Interactive (physical) |
| 6 | `opto` | Opto Inputs | ❌ | ✅ | HIL (M5StampPLC RLY2/3/4) |
| 7 | `can` | CAN loopback | ❌ | ✅ | HIL (M5StampPLC CAN) |
| 8 | `mqs` | MQS Audio Out | ❌ | ❌ | Interactive (in-run confirm) |
---
## Диагностика — строки detail
| Тест | Значение `detail` | Диагноз |
| --------- | ----------------------------------------------- | ------------------------------------------- |
| `sdram` | `addr=0x... exp=0x.. got=0x..` | Сбой ячейки по адресу |
| `sdram` | `SEMC not ready — DCD failed?` | DCD не инициализировал SEMC |
| `qspi` | `JEDEC: mfr=0xFF exp=0xEF` | Чип не отвечает / не пропаян |
| `qspi` | `JEDEC: unknown cap=0x..` | Неизвестный тип чипа |
| `qspi` | `erase verify failed at 0x...` | Сектор не стирается |
| `qspi` | `rw mismatch at 0x... exp=0x.. got=0x..` | Ошибка записи или чтения |
| `qspi` | `addr alias: 0x... mirrors 0x... (3-byte wrap)` | Dedicated 4-byte opcodes не работают |
| `usd` | `no card detected` | Карта не вставлена в слот |
| `usd` | `mount failed: <N>` | `f_mount()` вернул FRESULT N |
| `usd` | `write failed: <N>` | `f_write()` вернул FRESULT N |
| `usd` | `compare failed at offset <N>` | Данные после чтения не совпадают |
| `display` | `display init failed` | `bsp_display_init()` вернул ошибку |
| `display` | `<id> not confirmed` | Оператор не подтвердил / истёк таймаут 15 с |
| `buttons` | `btn1_press timeout` | Test_But_1 не нажата за 10 с |
| `buttons` | `btn2_press timeout` | Test_But_2 не нажата за 10 с |
| `mqs` | `mqs play error` | SAI3/DMA не запустился |
| `mqs` | `operator: no sound` | Нет звука / усилитель не работает |
| `opto` | `<id> mismatch: expected ACTIVE got INACTIVE` | Реле не переключило оптовход |
| `can` | `can_rx_ready: no frame received` | M5 не отправил фрейм / CAN не подключён |
| `can` | `rx id mismatch: expected 0x100 got 0x...` | Неверный ID принятого фрейма |
| `can` | `rx data mismatch: got XX XX XX XX` | Данные фрейма не совпадают |
| `can` | `tx failed: bsp_can_send returned <N>` | TX timeout или шина недоступна |
| `can` | `can_tx_verify: M5 did not confirm tx frame` | M5 не получил фрейм от таргета |
| любой | `confirm timeout` | pre-confirm не получен за 30 с |
| любой | `operator declined` | Получен `"confirmed":false` |
| любой | `critical test failed` | Предшествующий критичный тест провалился |
---
## HIL pytest — автоматическая верификация через firmware_test CDC ## HIL pytest — автоматическая верификация через firmware_test CDC
Два файла тестируют `test_opto` и `test_can` через реальный CDC-протокол v2. Два файла тестируют `test_opto` и `test_can` через реальный CDC-протокол v2.

View file

@ -163,7 +163,6 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
# ── Проверить FCB ─────────────────────────────────────────────────────────
if not args.fcb.exists(): if not args.fcb.exists():
print(f" ❌ FCB not found: {args.fcb}", file=sys.stderr) print(f" ❌ FCB not found: {args.fcb}", file=sys.stderr)
print( print(
@ -174,7 +173,6 @@ def main() -> int:
) )
return 1 return 1
# ── Найти HAB-образ ───────────────────────────────────────────────────────
hab_name = HAB_NAMES[args.firmware] hab_name = HAB_NAMES[args.firmware]
hab_path = BUILD_DIR / args.build_type / hab_name hab_path = BUILD_DIR / args.build_type / hab_name
@ -195,7 +193,6 @@ def main() -> int:
print(f" Target : {args.target}") print(f" Target : {args.target}")
print(f" Frequency : {args.frequency} Hz\n") print(f" Frequency : {args.frequency} Hz\n")
# ── Собрать объединённый образ ────────────────────────────────────────────
image = build_full_image(args.fcb, hab_path) image = build_full_image(args.fcb, hab_path)
if args.dry_run: if args.dry_run:
@ -205,7 +202,6 @@ def main() -> int:
print(f"\n Dry run — image saved to {out}") print(f"\n Dry run — image saved to {out}")
return 0 return 0
# ── Записать во Flash через pyOCD ─────────────────────────────────────────
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
suffix=f"_{args.firmware}_full.bin", delete=False suffix=f"_{args.firmware}_full.bin", delete=False
) as tmp: ) as tmp:

View file

@ -387,7 +387,6 @@ def main() -> None:
), ),
) )
# Группа: что прошивать (взаимоисключающие варианты)
target_group = parser.add_mutually_exclusive_group() target_group = parser.add_mutually_exclusive_group()
target_group.add_argument( target_group.add_argument(
"--firmware", "--firmware",
@ -420,9 +419,6 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
# Валидация: --firmware требует --build-type (уже есть default, но запомним)
# --bin-path: build-type игнорируется
# --erase-chip: несовместим с --ram-only
if args.erase_chip and args.ram_only: if args.erase_chip and args.ram_only:
parser.error("--erase-chip несовместим с --ram-only") parser.error("--erase-chip несовместим с --ram-only")

View file

@ -1,10 +1,10 @@
# service-tui — TUI сервисного инженера # service-tui — TUI сервисного инженера
TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе. TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе.
Написано на Python + [Textual](https://textual.textualize.io/). Работает на MacOS, Windows. Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows.
> Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков — > Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков —
> в [DEV_ARCH.md](docs/DEV_ARCH.md) > в [DEV_ARCH.md](DEV_ARCH.md).
--- ---
@ -17,7 +17,7 @@ TUI-приложение для диагностики и прошивки пл
```bash ```bash
┌────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────┐
│ service_tool vX.Y.Z │ service_tool v0.2.0
│ │ │ │
│ [LOGO_ART] │ │ [LOGO_ART] │
│ │ │ │
@ -27,6 +27,9 @@ TUI-приложение для диагностики и прошивки пл
└────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────┘
``` ```
Версия читается из `pyproject.toml` — при бампе версии мокап выше не нужно
обновлять руками, TUI подставит актуальную сама.
При потере соединения на любом другом экране сессия разрывается полностью — При потере соединения на любом другом экране сессия разрывается полностью —
TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над
подсказкой на 4 секунды появляется строка `⚠ <причина>` (например, подсказкой на 4 секунды появляется строка `⚠ <причина>` (например,
@ -56,37 +59,30 @@ TUI не пытается восстановить прежнее состоян
│ │ │ │
│ ████████████░░░░░░ ← без числового % │ │ ████████████░░░░░░ ← без числового % │
│ ┌────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────┐ │
│ │ ▶ Прошивка: firmware_test │ │ │ │ ▶ Сборка HAB-образа (HabImage)... │ │
│ │ ▶ Прошивка: TFT_BOOTLOADER_NEW │ │
│ │ ... │ │ │ │ ... │ │
│ └────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────┘
``` ```
**Если после «Загрузить» появилась ошибка, а плата всё ещё видна на этом же Лог виден постоянно (не только во время прошивки), прогресс-бар — только
экране** — это ожидаемо: логическая ошибка (не найден файл, не подошёл во время активной операции (скрыт в простое), без числового `%` — только
формат) не выкидывает на экран ожидания, потому что плата физически полоса и построчный лог в реальном времени. Панель выбора прошивки
подключена. Прочитайте сообщение в логе, поправьте выбор и нажмите ограничена по высоте и скроллится сама, если разрастается (варианты
«Загрузить» ещё раз. На экран ожидания TUI переключает только при реальном "Другое") — лог снизу гарантированно не сжимается меньше 6 строк.
физическом обрыве USB.
**"Другое" — для бинарников, собранных не в этом репозитории.** В **"Другое" — для бинарников, собранных не в этом репозитории.** В
`custom_binaries/` кладётся бинарник — сырой (код + таблица векторов, без `custom_binaries/` кладётся **сырой** образ (код + таблица векторов, без
FCB/IVT/DCD) либо уже готовый HAB-образ, в зависимости от источника. TUI FCB/IVT/DCD — то же самое, что `build/Debug/bootloader.bin` до сборки HAB).
сама достраивает недостающее на лету: TUI сама собирает из него загружаемый образ на лету, in-process через
Python API `spsdk` (без вызова внешних CLI-утилит):
1. Собирает загружаемый HAB-образ (добавляет IVT, +DCD — если включён 1. `HabImage` (spsdk) — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM")
тумблер "Использует SDRAM") 2. в Flash пишется явный FCB под выбранную память платы (не тот же
2. В Flash пишется явный FCB под выбранную память платы (не тот же
auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md §8.4`) W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`)
3. Образ прошивается стандартным адресом 3. образ прошивается с `0x60001000`, как обычно
**Нужен ли тумблер DCD — зависит от конкретного бинарника, не от того, в
каком виде он получен.** Одна и та же связка `bootloader + tft_app` не
требует DCD, а часть кастомных/легаси образов (например, старый загрузчик,
используемый на производстве) требует его независимо от формата файла. Если
не уверены, нужен ли конкретному образу DCD — уточните у того, кто его
предоставил, прежде чем прошивать.
**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не **Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не
нужно выставлять заново на каждой следующей плате: прошили одну, вынули нужно выставлять заново на каждой следующей плате: прошили одну, вынули
@ -105,8 +101,8 @@ Production/Custom этот шаг не нужен).
│ Переведите плату в нормальный режим: │ │ Переведите плату в нормальный режим: │
│ BOOT_MOD_1 → GND → Reset │ │ BOOT_MOD_1 → GND → Reset │
│ │ │ │
│ Автопереход через: 40с
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │ │ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
│ Автопереход через: 40с
└────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────┘
``` ```
@ -137,7 +133,6 @@ Production/Custom этот шаг не нужен).
``` ```
Что важно знать: Что важно знать:
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать - **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
все"/"Снять все". все"/"Снять все".
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена - **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
@ -157,7 +152,7 @@ Production/Custom этот шаг не нужен).
--- ---
## Рабочие процессы сервисного инженера ## Рабочие процессы сервисника
### Диагностика (firmware_test уже прошит) ### Диагностика (firmware_test уже прошит)
@ -184,12 +179,11 @@ Production/Custom этот шаг не нужен).
Для плат старых ревизий и любых образов, собранных не в этом репозитории. Для плат старых ревизий и любых образов, собранных не в этом репозитории.
```bash ```bash
1. Положить бинарник (сырой или уже HAB, см. раздел выше) в custom_binaries/ 1. Положить сырой бинарник (без FCB/IVT/DCD) в custom_binaries/
(или в директорию из SERVICE_CUSTOM_BINARIES_DIR) (или в директорию из SERVICE_CUSTOM_BINARIES_DIR)
2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen 2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD, если 3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD при необходимости
конкретно этот образ его требует (уточнить у источника файла) 4. Загрузить — TUI сама соберёт HAB-образ и запишет правильный FCB
4. Загрузить — TUI сама соберёт HAB-образ (если нужно) и запишет правильный FCB
5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже 5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже
подставлен, останется нажать «Загрузить» подставлен, останется нажать «Загрузить»
``` ```
@ -204,9 +198,30 @@ Production/Custom этот шаг не нужен).
--- ---
## Известные ограничения
- **Одна плата на столе одновременно.** В SDP/Flashloader-режиме плату
нельзя идентифицировать по UID — авто-прошивка по факту детекта без
подтверждения оператора убрала бы последний шанс заметить, что в руках
не та плата. Массового программирования (несколько плат параллельно)
нет и не планируется в этом виде — см. `docs/DEV_ARCH.md`, §8.
- **Циклический прогон тестов** (повторный автозапуск набора без ручного
нажатия на каждую итерацию) — отложен на пост-релиз, не входит в текущую
версию.
---
## Конфигурация (`.env`) ## Конфигурация (`.env`)
```ini ```ini
# USB VID:PID — BootROM SDP (константы NXP, не менять)
BOOTROM_VID=1fc9
BOOTROM_PID=0130
# USB VID:PID — Flashloader (константы NXP, не менять)
FLASHLOADER_VID=15a2
FLASHLOADER_PID=0073
# USB VID:PID — firmware_test CDC (наше устройство) # USB VID:PID — firmware_test CDC (наше устройство)
SERVICE_CDC_VID=1996 SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad SERVICE_CDC_PID=00ad
@ -215,7 +230,7 @@ SERVICE_CDC_PID=00ad
SERVICE_M5_VID=303a SERVICE_M5_VID=303a
SERVICE_M5_PID=4001 SERVICE_M5_PID=4001
# Директория с кастомными бинарниками для FlashScreen → "Другое". # Директория с сырыми кастомными бинарниками для FlashScreen → "Другое".
# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом # По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом
# с main.py в dev-режиме); создаётся автоматически при старте. # с main.py в dev-режиме); создаётся автоматически при старте.
# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries # SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries
@ -224,19 +239,14 @@ SERVICE_M5_PID=4001
# Release временно нестабилен — по умолчанию Debug. # Release временно нестабилен — по умолчанию Debug.
FIRMWARE_BUILD_TYPE=Debug FIRMWARE_BUILD_TYPE=Debug
# Уровень логирования. По умолчанию INFO (плюс WARNING принудительно для
# шумных модулей spsdk/libusbsio). DEBUG — полный лог, включая построчные
# HID-дампы каждой команды spsdk (для диагностики проблем прошивки).
# SERVICE_LOG_LEVEL=DEBUG
# Опционально: путь к директории лога TUI # Опционально: путь к директории лога TUI
# SERVICE_LOG_DIR=/tmp # SERVICE_LOG_DIR=/tmp
```
> `.env` не загружается в упакованном (frozen) приложении — standalone-бинарь # Уровень логирования. По умолчанию — INFO, при этом spsdk/libusbsio
> работает на встроенных значениях по умолчанию. Переменные окружения (не # принудительно приглушены до WARNING. DEBUG включает полный дамп,
> `.env`-файл) по-прежнему действуют и во frozen-режиме, если их выставить # включая сырые HID-пакеты spsdk (много строк на одну прошивку).
> перед запуском. # SERVICE_LOG_LEVEL=DEBUG
```
--- ---
@ -245,56 +255,55 @@ FIRMWARE_BUILD_TYPE=Debug
### Из монорепозитория (разработчик) ### Из монорепозитория (разработчик)
```bash ```bash
just host::service-setup # установить/обновить зависимости tools/production/ just host::service-setup # установить зависимости tools/production/
just host::service-tui # запустить TUI just host::service-tui # запустить TUI
``` ```
### Standalone-бинарь (сервисник) ### Standalone-бинарь (сервисник)
Распаковать `service-tui-vX.Y.Z-<os>.zip` в любую директорию и запустить
`service_tui` (`service_tui.exe` на Windows). Файл самодостаточен — не
требует установленного Python, `uv`, драйверов (Zadig/WinUSB) или сетевого
доступа.
### Сборка релизного бандла (разработчик)
```bash ```bash
just build::hab-all-release # или hab-all-debug — собрать HAB-образы заранее just host::package-tui
just host::package-tui # → tools/production/dist/service-tui-vX.Y.Z-<os>/ # → tools/production/dist/service-tui-vX.Y.Z-<os>/
``` ```
Устройство бандла (`_internal/`, `firmware/`, `custom_binaries/`) и детали Бандл (PyInstaller, onedir) самодостаточен — прошивка идёт напрямую через
сборки (`service_tui.spec`) — в [DEV_ARCH.md §14](DEV_ARCH.md#14-упаковка-pyinstaller-фаза-5). spsdk Python API (`app/flash_backend.py`), без вызова `tools/host/` ни
субпроцессом, ни как-либо иначе. Не нужны ни Zadig, ни установленный
> Если на Windows `package-tui` падает с `Permission denied` на шаге Python/uv на машине сервисника. Структура бандла и резолв путей во frozen —
> переименования — закройте запущенный `service_tui.exe` от предыдущей см. [DEV_ARCH.md](docs/DEV_ARCH.md), §14.
> сборки и повторите (см. `DEV_ARCH.md §14.4`).
--- ---
## Зависимости ## Зависимости
| Пакет | Версия | Назначение | | Пакет | Версия | Назначение |
| --------------- | ------ | -------------------------------------------------------------------------------------- | | --------------- | ------- | -------------------------------------------------------- |
| `textual` | ≥ 0.80 | TUI фреймворк | | `textual` | ≥ 0.80 | TUI фреймворк |
| `pyserial` | ≥ 3.5 | USB CDC ACM (firmware_test) + Serial (M5StampPLC) | | `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial |
| `spsdk` | 3.7.0 | прошивка in-process: SDP, McuBoot, HabImage | | `pyusb` | ≥ 1.0 | не используется в коде (детект SDP/CDC идёт через `spsdk`) — исторический остаток, кандидат на удаление из `pyproject.toml` |
| `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-бандла | | `pyinstaller` | ≥ 6.0 | сборка standalone-бандла (`just host::package-tui`) |
| `pyusb` | ≥ 1.0 | не используется текущей детект-логикой (см. `DEV_ARCH.md §2`), оставлен в зависимостях |
**Никакой рантайм-зависимости на `tools/host/` в смысле subprocess-вызовов
нет.** Прошивка выполняется in-process через `spsdk` (`app/flash_backend.py`).
Из `tools/host/dcd/` читаются только статичные data-блобы (`dcd.bin`,
`*_fdcb.bin`, `ivt_flashloader.bin`) — они отслеживаются в git, `just
host::setup-tools` для запуска TUI из исходников не требуется. `tools/host/
flash_usb.py` — независимый dev-CLI для `just host::flash*`, TUI его не
вызывает (см. [DEV_ARCH.md](docs/DEV_ARCH.md), §1/§8).
--- ---
## Логирование ## Логирование
```bash ```bash
tools/production/service_tui.log ← по умолчанию (рядом с main.py в dev, tools/production/service_tui.log ← по умолчанию (dev) / рядом с exe (frozen)
рядом с exe во frozen) $SERVICE_LOG_DIR/service_tui.log ← если задан в .env
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env / окружении
``` ```
Уровень по умолчанию: `INFO` для модулей приложения (`WARNING` для `textual` Уровень по умолчанию`INFO`; `textual`, `spsdk` и `libusbsio` понижены до
и принудительно для шумных модулей `spsdk`/`libusbsio`, которые на `DEBUG` `WARNING` независимо от root (иначе прошивка даёт ~135 строк сырых
печатают построчные HID-дампы каждой команды). Полный `DEBUG` — через HID-пакетов на одну операцию). `SERVICE_LOG_LEVEL=DEBUG` включает полный
`SERVICE_LOG_LEVEL=DEBUG`. TUI не пишет в stdout — Textual захватывает DEBUG везде, включая эти модули — используется при разборе проблем на
терминал. железе. TUI не пишет в stdout — Textual захватывает терминал.

View file

@ -50,8 +50,6 @@ class ServiceApp(App):
self.push_screen(WaitingScreen()) self.push_screen(WaitingScreen())
# ── Переходы между экранами ─────────────────────────────────────────────── # ── Переходы между экранами ───────────────────────────────────────────────
@on(WaitingScreen.DeviceDetected)
@on(WaitingScreen.DeviceDetected) @on(WaitingScreen.DeviceDetected)
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None: def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
if event.mode == AppMode.FLASHING: if event.mode == AppMode.FLASHING:
@ -97,7 +95,6 @@ class ServiceApp(App):
@on(DiagScreen.DiagDone) @on(DiagScreen.DiagDone)
def _on_diag_done(self, event: DiagScreen.DiagDone) -> None: def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
"""После диагностики — отключиться, вернуться в Waiting."""
self._disconnect() self._disconnect()
self.switch_screen(WaitingScreen(disconnect_reason=event.reason)) self.switch_screen(WaitingScreen(disconnect_reason=event.reason))

View file

@ -33,11 +33,9 @@ from .models import TestInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Таймаут чтения одной строки (сек)
_READLINE_TIMEOUT_S = 0.1 _READLINE_TIMEOUT_S = 0.1
# Таймаут ping→pong при подключении (сек)
_PING_TIMEOUT_S = 5.0 _PING_TIMEOUT_S = 5.0
# Таймаут ожидания событий теста (сек) — длиннее самого долгого теста (SDRAM ~15 с) # Длиннее самого долгого теста (SDRAM ~15 с)
_TEST_EVENT_TIMEOUT_S = 120.0 _TEST_EVENT_TIMEOUT_S = 120.0

View file

@ -30,7 +30,6 @@ import serial.tools.list_ports
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# VID/PID M5StampPLC
_M5_VID = int(os.environ.get("SERVICE_M5_VID", "0x303A"), 16) _M5_VID = int(os.environ.get("SERVICE_M5_VID", "0x303A"), 16)
_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16) _M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)

View file

@ -51,12 +51,10 @@ from .models import ConfirmRequest, TestResult, TestStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Задержки для HIL
_RELAY_ON_S = 0.15 _RELAY_ON_S = 0.15
_RELAY_OFF_S = 0.50 _RELAY_OFF_S = 0.50
# Карта confirm_id → реле M5 для opto-теста # confirm_id → (relay_num, target_state)
# Формат: confirm_id → (relay_num, target_state)
_OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = { _OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
"opto_in1_active": (3, True), "opto_in1_active": (3, True),
"opto_in1_inactive": (3, False), "opto_in1_inactive": (3, False),
@ -66,7 +64,6 @@ _OPTO_RELAY_MAP: dict[str, tuple[int, bool]] = {
"opto_rs_inactive": (2, False), "opto_rs_inactive": (2, False),
} }
# CAN параметры
_CAN_RX_ID = 0x100 _CAN_RX_ID = 0x100
_CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF] _CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
_CAN_TX_ID = 0x200 _CAN_TX_ID = 0x200
@ -80,13 +77,13 @@ _TIMEOUT_EVENT_TYPE = "_timeout"
class OrchestratorEventType(Enum): class OrchestratorEventType(Enum):
TEST_BEGIN = auto() # тест начался TEST_BEGIN = auto()
TEST_RESULT = auto() # тест завершился TEST_RESULT = auto()
TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.) TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.)
CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone) CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone)
CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически
BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm) BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm)
SUMMARY = auto() # итог всей сессии SUMMARY = auto()
ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту

View file

@ -24,7 +24,6 @@ class TestListPanel(Widget):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
# test_id → Checkbox для быстрого доступа
self._checkboxes: dict[str, Checkbox] = {} self._checkboxes: dict[str, Checkbox] = {}
# test_id → True если HIL-тест недоступен без M5 (постоянное состояние, # test_id → True если HIL-тест недоступен без M5 (постоянное состояние,
# не зависящее от прогона). Отдельно от Checkbox.disabled, который # не зависящее от прогона). Отдельно от Checkbox.disabled, который

View file

@ -173,9 +173,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
self._stop_connection_watch() self._stop_connection_watch()
def _check_sdp_present(self) -> bool: def _check_sdp_present(self) -> bool:
# Не считаем потерей соединения, если идёт активная операция — # Не считаем потерей соединения, если идёт активная операция — обрыв
# flash_usb.py сам обработает реальный обрыв через subprocess. # в этом случае обнаружит и обработает сам flash_backend
# обрыв в этом случае обнаружит и обработает сам flash_backend
# (ConnectionLostError, см. Фазу 4), не watcher. # (ConnectionLostError, см. Фазу 4), не watcher.
if self._flashing: if self._flashing:
return True return True

View file

@ -6,42 +6,41 @@
> взаимодействия с firmware/M5, экранную архитектуру Textual, известные > взаимодействия с firmware/M5, экранную архитектуру Textual, известные
> особенности фреймворка. > особенности фреймворка.
> Пользовательская документация (экраны, запуск, конфигурация, > Пользовательская документация (экраны, запуск, конфигурация,
> рабочие процессы сервисника) — в [README.md](../README.md). > рабочие процессы сервисника) — в [README.md](README.md).
--- ---
## 1. Структура проекта ## 1. Структура проекта
## 1. Структура проекта
```bash ```bash
tools/production/ tools/production/
├── main.py ← точка входа ├── main.py ← точка входа: логирование (Р12) + ServiceApp().run()
├── pyproject.toml ← зависимости uv ├── pyproject.toml ← зависимости uv (включая spsdk==3.7.0)
├── dist/ ← дистрибутивы программы (PyInstaller)
├── uv.lock ├── uv.lock
├── service_tui.spec ← PyInstaller spec ├── service_tui.spec ← PyInstaller spec (Фаза 5, onedir)
├── custom_binaries/ ← runtime, создаётся автоматически; ├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
│ сырые/готовые бинарники для FlashScreen → «Другое» │ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
└── app/ ← implicit namespace package ├── tests/
│ └── test_flash_backend.py ← unit-тесты flash_backend.py (45 тестов, без event loop)
├── spike/ ← Фаза 0, де-риск spsdk API (в релиз не идёт)
└── app/
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов ├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
├── app.tcss ← единый файл стилей для всех экранов ├── app.tcss ← единый файл стилей для всех экранов
├── 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 ├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
├── m5_client.py ← async M5StampPLC клиент ├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── flash_backend.py ← spsdk 3.7.0 in-process: SDP, McuBoot, HabImage ├── usb_ports.py ← resolve_serial_port() — резолв COM/tty по VID:PID (Р8)
├── flasher.py ← async-обёртка над flash_backend для Textual workers ├── flash_backend.py ← синхронное ядро прошивки: прямой spsdk API (McuBoot/SDP/HabImage),
├── usb_ports.py ← резолвер serial-портов по VID:PID │ zero Textual/asyncio импортов, тестируется без event loop
├── flasher.py ← async-обёртка над flash_backend.py (asyncio.to_thread)
├── 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, 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 после прошивки
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB ├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
@ -52,6 +51,15 @@ tools/production/
└── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown └── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown
``` ```
> **Разделение dev-CLI / production-TUI:** `tools/host/flash_usb.py` (subprocess
> sdphost/blhost, используется just-рецептами `just host::flash*`) и
> `app/flash_backend.py` (прямой spsdk Python API) — две независимые
> реализации одной и той же логики прошивки. `flash_backend.py` — прямой
> порт `flash_usb.py` на spsdk API (см. заголовок модуля), но TUI больше не
> вызывает `flash_usb.py` ни субпроцессом, ни как библиотеку. `tools/host/`
> используется TUI только как источник статичных data-блобов
> (`tools/host/dcd/*.bin`) в dev-режиме — см. §8.
--- ---
## 2. Концепция ## 2. Концепция
@ -63,31 +71,48 @@ graph LR
subgraph app["app/"] subgraph app["app/"]
FC["firmware_client.py\nUSB CDC ACM, UTF-8"] FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
M5["m5_client.py\nSerial JSON-lines, UTF-8"] M5["m5_client.py\nSerial JSON-lines, UTF-8"]
FL["flasher.py + flash_backend.py\nspsdk in-process: SDP/McuBoot/HabImage"] FL["flasher.py\nasyncio.to_thread мост"]
FB["flash_backend.py\nspsdk: McuBoot/SDP/HabImage"]
OR["orchestrator.py\nconfirm/progress/timeout router"] OR["orchestrator.py\nconfirm/progress/timeout router"]
end end
TUI --> FC & M5 & FL & OR TUI --> FC & M5 & FL & OR
FL --> FB
end end
subgraph Board["Плата TFT (MIMXRT1052)"] subgraph Board["Плата TFT (MIMXRT1052)"]
FW["firmware_test\n(USB CDC)"] FW["firmware_test\n(USB CDC)"]
ROM["BootROM SDP\n(1FC9:0130)"] ROM["BootROM SDP\n(1FC9:0130)"]
FLD["Flashloader\n(15A2:0073, RAM-резидент)"]
end end
subgraph HIL["HIL стенд (опционально)"] subgraph HIL["HIL стенд (опционально)"]
M5HW["M5StampPLC\nRLY14 + CAN"] M5HW["M5StampPLC\nRLY14 + CAN"]
end end
FC |"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW FC <-->|"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL |"spsdk (libusbsio HID)\nVID:PID 1FC9:0130 / 15A2:0073"| ROM FB -->|"SDP.write_file + jump_and_run\n(spsdk.sdp)"| ROM
M5 |"JSON-lines\nSerial"| M5HW ROM -.->|"загружает ivt_flashloader.bin"| FLD
FB -->|"McuBoot: erase/write_memory/reset\n(spsdk.mboot)"| FLD
M5 <-->|"JSON-lines\nSerial"| M5HW
M5HW -->|"RLY14"| Board M5HW -->|"RLY14"| Board
``` ```
> **Детект USB:** `flash_backend.detect_sdp()`/`detect_cdc()` используют spsdk > **Детект USB:** `Flasher.detect_sdp()`/`detect_cdc()` делегируют в
> напрямую (`SdpUSBInterface.scan()` / `MbootUSBInterface.scan()`, HID-транспорт > `flash_backend.detect_sdp()`/`detect_cdc()` (Р7 — `spsdk`-сканеры
> через `libusbsio`). CDC firmware_test и M5StampPLC резолвятся через > `SdpUSBInterface.scan()`/`serial.tools.list_ports`, БЕЗ `pyusb`: BootROM
> `pyserial` (`usb_ports.py::resolve_serial_port()`, `m5_client.py`). > SDP не создаёт serial-порт на macOS, но `spsdk` видит его нативно через
> HID/libusbsio без Zadig на Windows). `usb_ports.resolve_serial_port()`
> резолвит CDC-порты (firmware_test, M5) по VID:PID, а не по имени порта —
> имя не переносимо между перевтыкиваниями (см. Р8, `usb_ports.py`).
> M5StampPLC детектируется отдельно в `m5_client.py` (VID/PID из `.env`
> см. раздел 5).
>
> **`flash_backend.py` не вызывает `tools/host/flash_usb.py`** ни
> субпроцессом, ни как библиотеку — это прямой порт той же логики на
> spsdk Python API (`McuBoot`/`SDP`/`HabImage` вместо `sdphost`/`blhost`/
> `nxpimage` CLI), провалидированный байт-в-байт на живом железе (Фаза 0).
> `flash_usb.py` остаётся независимым dev-CLI для `just host::flash*`
> см. §8 (конвейер сборки) и §6.2 (обработка ошибок прошивки).
--- ---
@ -105,7 +130,7 @@ 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 --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое
POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
@ -117,13 +142,6 @@ stateDiagram-v2
`ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на `ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на
сообщения `DeviceDetected`/`FlashDone`/`DiagDone`. сообщения `DeviceDetected`/`FlashDone`/`DiagDone`.
> **Важная деталь, не показанная на диаграмме**:
> `FLASHING --> WAITING` по стрелке «ошибка» срабатывает **только** при
> физическом обрыве USB (`FlashResult.connection_lost=True`). Логическая
> ошибка (файл не найден, битый custom-бинарь) — плата на месте, экран
> остаётся на `FLASHING` (нет перехода состояния вообще, поэтому на
> диаграмме это не отдельная стрелка). См. §6.
--- ---
## 4. Обработка confirm_request ## 4. Обработка confirm_request
@ -163,7 +181,9 @@ flowchart TD
**Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно **Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно
одним событием `SUMMARY` — настоящим от firmware или синтетическим одним событием `SUMMARY` — настоящим от firmware или синтетическим
(`aborted: true`), если чтение порта оборвалось по таймауту. (`aborted: true`), если чтение порта оборвалось по таймауту. Без этой гарантии
зависший тест блокировал бы кнопки "Выйти" и повторного запуска навсегда
(исторический баг, см. `CHANGELOG.md`).
--- ---
@ -191,49 +211,85 @@ flowchart TD
PID — при детекте ориентироваться на `just host::m5-scan`, а не на PID — при детекте ориентироваться на `just host::m5-scan`, а не на
документацию, если она когда-либо разойдётся с кодом. документацию, если она когда-либо разойдётся с кодом.
**Важно на будущее:** документация (`HIL_BENCH.md`/`HIL_HOW_TO.md`) местами не
успевает за изменениями `agent.py`. При любых будущих изменениях протокола
агента (новые команды, смена формата ответа) — сверяться напрямую через
`grep` по `tools/hil/m5/agent.py`, а не полагаться только на документацию.
--- ---
## 6. Мониторинг соединения и разрыв сессии ## 6. Мониторинг соединения и разрыв сессии
### 6.1 Простой (`ConnectionWatcherMixin`)
`ConnectionWatcherMixin` (`screens/connection_watcher.py`) подключается к `ConnectionWatcherMixin` (`screens/connection_watcher.py`) подключается к
`FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине. `FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине.
- **На `FlashScreen`** — проверка приостановлена во время активной - **На `FlashScreen`** — проверка приостановлена во время активной
прошивки/erase (`self._flashing == True`). прошивки/erase (обрыв в этом случае обнаруживает сам `flash_backend.py`,
см. §6.2, — не watcher).
- **На `DiagScreen`** — проверка приостановлена во время прогона тестов - **На `DiagScreen`** — проверка приостановлена во время прогона тестов
(обрыв надёжнее детектирует таймаут чтения порта внутри `Orchestrator`, не (обрыв надёжнее детектирует таймаут чтения порта внутри `Orchestrator`, не
просто исчезновение устройства из списка). просто исчезновение устройства из списка).
- При срабатывании — `ConnectionLost` message → экран постит
`FlashDone(success=False, target=None)` / `DiagDone(reason=...)`
`ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает
на `WaitingScreen(disconnect_reason=...)`. `FlashDone` в этой ветке не несёт
`preset` — «липкий» выбор (см. §8) сохраняется отдельно, в момент нажатия
«Загрузить», а не при завершении прошивки.
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
продолжает обычный автодетект.
**Три независимых механизма детекта обрыва**: Архитектурное решение: **сессия никогда не восстанавливается** — после
1. **`ConnectionWatcherMixin` в простое** — периодический опрос шины.
2. **`flash_backend.py` во время активной операции** — spsdk бросает
`SPSDKConnectionError`/`SPSDKTimeoutError` (оба ловятся явным кортежем
`_CONNECTION_LOST_EXCEPTIONS``SPSDKTimeoutError` НЕ наследует
`SPSDKConnectionError`, оба - потомки `SPSDKError`
3. **Вариант B** — некоторые команды spsdk (`flash_erase_all`,
`write_memory` и т.п.) при таймауте не бросают исключение, а тихо
возвращают `False`. `_fail_command()` в этом случае сам проверяет
`_sdp_still_present()`: плата пропала с шины → `ConnectionLostError`;
плата на месте → обычная `FlashBackendError`.
Оба механизма 2 и 3 транслируются в `Flasher.flash()`/`erase_chip()` как
`FlashResult(ok: bool, connection_lost: bool)`**не голый `bool`**. Это
принципиально для `FlashScreen`:
- `connection_lost=True``FlashDone(target=None, error_message=...)`
`ServiceApp` переключает на `WaitingScreen(disconnect_reason=...)`.
- `connection_lost=False`**экран не покидает себя**.
Плата физически на месте, сообщение об ошибке уже в `#flash-log`, кнопки
разблокированы (`_set_busy(False)`) — оператор может поправить выбор
(другой файл, другой вариант памяти) и повторить, не выдёргивая USB.
`WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
продолжает обычный автодетект.
**Cессия никогда не восстанавливается** — после
разрыва TUI не пытается определить, вернулась ли та же плата, просто стартует разрыва TUI не пытается определить, вернулась ли та же плата, просто стартует
заново с нуля. диагностику с нуля.
### 6.2 Во время активной операции (`FlashBackendError`, Фаза 4/4a)
Пока `FlashScreen._flashing == True`, watcher приглушён (§6.1) — обрыв в
этот момент обнаруживает сам `flash_backend.py` через иерархию исключений:
```
FlashBackendError (connection_lost: bool = False)
├── ConnectionLostError (connection_lost = True — обёртка
│ над SPSDKConnectionError/SPSDKTimeoutError)
├── DeviceNotFoundError (SDP не найден ДО начала операции)
├── FlashLoaderTimeoutError (Flashloader не поднялся за 10с)
└── HabBuildError (сборка HAB не удалась, к USB не относится)
```
- **`_CONNECTION_LOST_EXCEPTIONS = (SPSDKConnectionError, SPSDKTimeoutError)`**
оба варианта прилетают на обрыве USB (`SPSDKTimeoutError` — потомок
`SPSDKError`, но **не** `SPSDKConnectionError`; read-фаза после write может
отдать голый таймаут вместо connection error). Ловятся кортежем на всех
точках отказа: `load_flashloader`, `flash` (основная + `ram_only` ветки),
`erase_chip`.
- **Вариант B для команд, возвращающих `False` без исключения** (Р10,
`_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory`
иногда просто возвращают `False` вместо исключения. В этом случае
`_fail_command()` выполняет быстрый `_sdp_still_present()` (обёрнутый в
`try/except` — любая ошибка самой проверки трактуется как «устройства
нет», т.к. шина к этому моменту уже нестабильна): устройство пропало →
`ConnectionLostError`, устройство на месте → обычный `FlashBackendError` с
текстом ошибки операции. Проверка добавляется **только в error-путь**, на
happy path не влияет.
- **`Flasher._run_flash_op()`** (`flasher.py`) конвертирует
`FlashBackendError` обратно в `FlashResult(ok=False,
connection_lost=exc.connection_lost)` + событие `FlashProgress(phase="error")`.
Отдельный `except Exception` — safety net на любое непредвиденное
исключение (гарантирует `ok=False` вместо зависших кнопок); `_format_error_message()`
добавляет префикс «Соединение с платой потеряно» для `connection_lost=True`.
- **`FlashScreen`** различает результат (см. docstring `FlashDone`, §9):
`connection_lost=True``WaitingScreen` (тот же маркер `target=None`, что
и watcher-детект в простое, текст ошибки прокидывается через
`FlashDone.error_message`); `connection_lost=False` → плата на месте,
экран остаётся на `FlashScreen` (иначе `WaitingScreen` почти мгновенно
переоткрывал бы `FlashScreen` заново и уничтожал `#flash-log` раньше, чем
оператор успевал прочитать сообщение об ошибке).
USB-интерфейс из `load_flashloader()` закрывается в `finally`
(`_close_iface_quiet`) на любом исходе — защита от утечки HID-хэндла в
редком окне «интерфейс получен → USB выдернут → `McuBoot.__enter__` упал».
--- ---
@ -272,14 +328,15 @@ AppFrame {
### 8.1 Проблема ### 8.1 Проблема
Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются заранее Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются
(`just build::hab-*`) и всегда идут на плату с W25Q128 — для них auto-config `nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 —
Flashloader достаточен. Для сторонних/легаси бинарников (старые платы, для них auto-config Flashloader (`configure-memory 0xC0000007` →
W25Q256/512) это не так: auto-config Flashloader не документирован как `0xF000000F`, см. `HOW_TO_FLASH.md`) достаточен. Для сторонних/легаси
надёжный для 4-байтной адресации, а сами бинарники приходят «сырыми» (код + бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader
таблица векторов, без FCB/IVT/DCD) либо уже готовым HAB-образом — зависит от не документирован как надёжный для 4-байтной адресации, а сами бинарники
источника. Решение — собирать HAB на лету (если нужно) и писать FCB явно, а приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат,
не полагаться на auto-config. что `inputImageFile` в `hab_*.yaml` до сборки). Решение — собирать HAB
на лету и писать FCB явно, а не полагаться на auto-config.
### 8.2 Модели (`models.py`) ### 8.2 Модели (`models.py`)
@ -306,44 +363,76 @@ class FlashPreset:
одинаковых плат подряд — вставил, TUI уже подставила прошлый выбор файла/ одинаковых плат подряд — вставил, TUI уже подставила прошлый выбор файла/
памяти/DCD, нажал «Загрузить», вынул, вставил следующую. памяти/DCD, нажал «Загрузить», вынул, вставил следующую.
**Нужен ли DCD — implementation-defined, зависит от конкретного бинарника, Рассматривался отдельный режим «массовое программирование» (авто-прошивка
не от его формата (сырой/готовый HAB).** Правило «сырой → включить DCD, по факту детекта SDP, без нажатия кнопки на каждую плату) — отклонён:
готовый HAB → выключить» **неверно как общее правило**: например, в связке в SDP/Flashloader-режиме нет способа прочитать UID платы, авто-старт без
`bootloader + tft_app` сам `bootloader` не требует DCD, а часть кастомных подтверждения оператора убирает последний шанс заметить, что в руках не та
бинарников (в т.ч. старый загрузчик, используемый на производстве) требует плата. Оставлена только «липкая» память выбора (этот раздел).
DCD независимо от того, в каком виде получен файл. Оператор должен знать
по конкретному образу, инициализирует ли он SDRAM самостоятельно — TUI не
может определить это автоматически по содержимому файла.
### 8.3 Конвейер сборки (`flasher.py`) ### 8.3 Конвейер сборки — прямые вызовы spsdk (`flash_backend.py`)
```bash Сборка HAB-образа и прошивка выполняются **in-process** через Python API
Flasher.flash(target=CUSTOM, bin_path, use_dcd, fcb_variant, progress_cb) `spsdk` — никакого subprocess/CLI (`nxpimage`/`sdphost`/`blhost`), в отличие
└── _flash_custom() от dev-CLI `tools/host/flash_usb.py`, который остаётся отдельной,
├── _build_custom_hab(raw_bin, use_dcd, progress_cb) независимой реализацией на тех же CLI-утилитах (см. §1, врезка про
│ └── flash_backend.build_custom_hab() — in-process spsdk API: разделение dev-CLI/production-TUI).
│ Config (family=mimxrt1050, startAddress=0x60000000,
│ ivtOffset=0x1000, initialLoadSize=0x2000, ```
│ + DCDFilePath, если use_dcd) → HabImage.export() Flasher.flash(target=CUSTOM, bin_path, use_dcd, fcb_variant, progress_cb) [flasher.py]
└── _run_flash_op(flash_backend.flash, hab_bin, fcb_path=...) └── _flash_custom() [flasher.py]
временный HAB-образ удаляется после прошивки ├── asyncio.to_thread(flash_backend.build_custom_hab, raw_bin, use_dcd)
(finally: shutil.rmtree(hab_bin.parent)) │ ├── _make_hab_config() — генерирует YAML с АБСОЛЮТНЫМИ путями
│ │ во временном work_dir (tempfile.mkdtemp), + DCDFilePath
│ │ на real_dcd_bin_path() если use_dcd
│ ├── Config.create_from_file() + HabImage.get_validation_schemas_from_cfg()
│ │ + cfg.check() — валидация конфига (spsdk.image.hab.hab_image)
│ ├── HabImage.load_from_config(cfg).export() — сборка байт HAB-образа
│ │ в памяти (та же логика, что nxpimage CLI, см. Фазу 0 — golden-тест
│ │ byte-exact, test_build_custom_hab_bytes_match_golden)
│ └── эмитит progress_cb(phase="hab_build", 0% → 100%)
└── asyncio.to_thread(flash_backend.flash, hab_bin, fcb_path=fcb_blob_path(...))
├── load_flashloader() — SDP.write_file()+jump_and_run(), ждёт
│ поднятия McuBoot (wait_for_flashloader, poll scan())
└── with McuBoot(iface): configure_flexspi() → flash_erase_region()
→ write_fcb_explicit(fcb_path) → write_memory(hab_bin)
→ reset(reopen=False)
(finally: shutil.rmtree(hab_bin.parent) — временная директория
build_custom_hab() удаляется целиком, не только *.bin)
``` ```
`dcd/dcd.bin` (`tools/host/dcd/dcd.bin`) — один и тот же файл независимо от `dcd/dcd.bin` — один и тот же файл независимо от проекта (SEMC/SDRAM-init не
проекта (SEMC/SDRAM-init не зависит от того, что именно исполняется), простой зависит от того, что именно исполняется), поэтому просто константный путь,
константный путь, без вариантов. Резолвится через `flash_backend._host_dcd_dir()` без вариантов. Пути к data-блобам (`dcd.bin`, `*_fdcb.bin`,
— двухрежимный (dev/frozen), см. §14. `ivt_flashloader.bin`) резолвятся двухрежимно (`_host_dcd_dir()`,
`flash_backend.py`): в dev — `tools/host/dcd/` (те же файлы, что использует
`flash_usb.py`, отслеживаются в git), в frozen-бандле —
`sys._MEIPASS/data` (см. §14).
### 8.4 Явная запись FCB вместо auto-config ### 8.4 Явная запись FCB вместо auto-config (`write_fcb_explicit`)
`flash_backend.py::write_fcb_explicit()``write_memory(0x60000000, fcb_bin)`, ```python
буквальная запись 512-байтного FCB-блоба (tag `FCFB`), а не magic option word def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None:
`0xF000000F`. Обязателен для кастомных бинарей — auto-config Flashloader """Пишет буквальный FCB-блоб (512 байт) в Flash[FLASH_BASE] (custom-бинари).
проверен только для W25Q128 (см. §Известные открытые вопросы).
Штатный путь (`firmware_test`/`bootloader`/`app` из `build/<Type>/`) не См. flash_usb.py::write_fcb_explicit — nxpimage не кладёт FCB в HAB-образ,
затрагивается — использует auto-config, как и раньше. поэтому для произвольных чипов нужен явный блоб под конкретный memory chip.
"""
```
`mboot.write_memory(FLASH_BASE, data, mem_id=0)` — буквальная запись
512-байтного FCB-блоба (tag `FCFB`), а не magic option word `0xF000000F`
(`write_fcb_auto`, применяется только для штатных
`firmware_test`/`bootloader`/`app`, см. `flash()` в `flash_backend.py`:
параметр `fcb_path=None` → auto-config). Активируется передачей
`fcb_path` в `flash_backend.flash()` — путь без него не тронут: штатная
прошивка идёт через auto-config `write_fcb_auto`, как и раньше.
Таймаут для `flash_erase_all` (chip erase) увеличен до `ERASE_ALL_TIMEOUT_MS
= 200_000` мс (эквивалент `blhost -t 200000`): W25Q512 стирается заметно
дольше W25Q128, дефолтного таймаута McuBoot не хватало.
`flash_erase_region` (стирание пары секторов под FCB+HAB при обычной
прошивке) не трогали — там масштаб на порядки меньше, дефолта достаточно
независимо от чипа.
### 8.5 UI (`flash.py`) ### 8.5 UI (`flash.py`)
@ -359,13 +448,6 @@ Flasher.flash(target=CUSTOM, bin_path, use_dcd, fcb_variant, progress_cb)
дополнительно защищён `min-height: 6` — лог гарантированно виден даже в дополнительно защищён `min-height: 6` — лог гарантированно виден даже в
худшем случае. худшем случае.
**Троттлинг лога:** прогресс-бар обновляется на каждом
событии `FlashProgress`, но `#flash-log` для фазы `write` пишет только при
пересечении 10%-границы — без этого запись HAB-образа даёт ~135 строк в лог
на одну прошивку. Первая строка фазы (`"Запись <имя> (<размер> байт)"`) всегда
проходит; остальные фазы (`configure`/`erase`/`fcb`/`reset`/`error`) логируются
без троттлинга — их и так немного.
--- ---
## 9. Архитектура экранов ## 9. Архитектура экранов
@ -390,7 +472,7 @@ graph TB
subgraph Clients["Клиенты"] subgraph Clients["Клиенты"]
FC["FirmwareClient"] FC["FirmwareClient"]
M5["M5Client"] M5["M5Client"]
FL["Flasher\n(async) + flash_backend\n(spsdk in-process)"] FL["Flasher"]
end end
WS -->|"DeviceDetected(FLASHING)"| FS WS -->|"DeviceDetected(FLASHING)"| FS
@ -429,7 +511,7 @@ sequenceDiagram
OP->>TUI: запустить service_tui OP->>TUI: запустить service_tui
TUI->>WS: push_screen() TUI->>WS: push_screen()
WS->>WS: USB poll каждые 1.5 с WS->>WS: spsdk/list_ports poll каждые 1.5 с
OP->>FW: подключить плату USB OP->>FW: подключить плату USB
WS->>TUI: DeviceDetected(DIAGNOSING) WS->>TUI: DeviceDetected(DIAGNOSING)
@ -489,7 +571,7 @@ sequenceDiagram
--- ---
## 11. Версионирование firmware и TUI ## 11. Версионирование firmware
`firmware_test` версионируется через CMake `firmware_test` версионируется через CMake
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через (`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
@ -502,10 +584,7 @@ sequenceDiagram
отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из
`pyproject.toml` напрямую через `tomllib` (stdlib). `importlib.metadata` `pyproject.toml` напрямую через `tomllib` (stdlib). `importlib.metadata`
сознательно не используется — проект не ставится как пакет сознательно не используется — проект не ставится как пакет
(`tool.uv.package = false`), метаданных может не быть. Резолв (`tool.uv.package = false`), метаданных может не быть.
`Path(__file__).resolve().parents[2] / "pyproject.toml"` одинаково корректен
в dev и frozen (относительный от модуля, а не абсолютный) — при условии, что
`service_tui.spec` кладёт `pyproject.toml` в корень бандла (см. §14).
--- ---
@ -544,9 +623,7 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
`mount_all()`. `mount_all()`.
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня - **`CSS_PATH` резолвится относительно файла класса**, не относительно корня
проекта — постоянно расходится при рефакторинге структуры. Решение: один проекта — постоянно расходится при рефакторинге структуры. Решение: один
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`. Во frozen `CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
дополнительно требует, чтобы `app.tcss` физически лежал в бандле по тому же
относительному пути (см. §14).
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает - **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает
ширину по умолчанию равную длине заголовка — длинный контент обрезается ширину по умолчанию равную длине заголовка — длинный контент обрезается
независимо от `height` строки. Нужно использовать `add_column(label, независимо от `height` строки. Нужно использовать `add_column(label,
@ -570,112 +647,123 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
--- ---
## 14. Упаковка ## 14. Упаковка PyInstaller и frozen-резолв путей (Фаза 5)
### 14.1 Структура бандла `service_tui.spec` собирает standalone-бандл (onedir — старт быстрее, чем
onefile с распаковкой во временную директорию на каждый запуск). Сборка —
через just-рецепт `just host::package-tui` (не `service-build`, см.
README «Запуск»): запускает PyInstaller, докладывает `firmware/<Type>/*_hab.bin`
из `BUILD_DIR`, переименовывает результат в `dist/service-tui-vX.Y.Z-<os>/`
(версия — из `pyproject.toml`).
```bash Целевая структура бандла:
```
service-tui-vX.Y.Z-<os>/ service-tui-vX.Y.Z-<os>/
├── service_tui[.exe] ├── service_tui[.exe]
├── _internal/ ├── _internal/
│ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data │ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data
│ └── ... ← рантайм PyInstaller, libusbsio (из Analysis) │ └── ... ← рантайм PyInstaller, libusbsio
├── firmware/ ├── firmware/
│ └── <Type>/firmware_test_hab.bin ← копируется post-build │ └── <Type>/firmware_test_hab.bin
└── custom_binaries/ ← пустая, для оператора └── custom_binaries/ ← пустая, создаётся оператором/автоматически
``` ```
Два разных механизма наполнения — не взаимозаменяемы: Каждый модуль, которому нужен путь к данным, сам решает dev vs frozen через
`getattr(sys, "frozen", False)` — единообразный паттерн по всему `app/`:
- **`_internal/data/`** — через `datas` в `service_tui.spec`
(`collect_data_files("spsdk")` + `tools/host/dcd/*.bin`). Резолвится в
рантайме через `sys._MEIPASS` (для onedir `_MEIPASS` == `_internal/`).
- **`firmware/`** — PyInstaller `datas` физически не может положить файл
вне `_internal/`, поэтому это отдельный **post-build copy-шаг** в
`just host::package-tui` (не часть `.spec`), копирующий `build/<Type>/*_hab.bin`
в бандл. Резолвится в рантайме через `Path(sys.executable).resolve().parent`
(сиблинг exe, не `_MEIPASS`) — сознательный выбор: HAB-образы должны быть
легко заменяемы без пересборки бандла.
- **`custom_binaries/`** — создаётся дважды, независимо: приложением само
при первом запуске (`flasher.py::_resolve_custom_binaries_dir()`,
`mkdir(exist_ok=True)`) и заодно явно в `package-tui` (`mkdir -p` перед
финальным переименованием) — избыточно, но безвредно, бандл выглядит
«полным» ещё до первого запуска.
### 14.2 Двухрежимный резолв путей (`flash_backend.py`)
Все функции, отдающие пути к data-файлам, различают dev/frozen:
| Функция | Dev | Frozen | | Функция | Dev | Frozen |
| -------------------------------------------------------------------- | -------------------------------- | ------------------------------------------- | | --- | --- | --- |
| `firmware_hab_path()` | `BUILD_DIR`/`build/<Type>/` | `sys.executable.parent / "firmware"` | | `flash_backend._host_dcd_dir()` | `tools/host/dcd/` | `sys._MEIPASS/data` |
| `_host_dcd_dir()` | `tools/host/dcd/` | `sys._MEIPASS / "data"` | | `flash_backend.firmware_hab_path()` | `$BUILD_DIR/<Type>/*_hab.bin` (репо `build/`) | `<exe_dir>/firmware/<Type>/*_hab.bin` |
| `flashloader_bin_path()` / `real_dcd_bin_path()` / `fcb_blob_path()` | производные от `_host_dcd_dir()` | | | `flasher._resolve_custom_binaries_dir()` | `tools/production/custom_binaries/` | `<exe_dir>/custom_binaries/` (override — `SERVICE_CUSTOM_BINARIES_DIR`) |
| `_resolve_custom_binaries_dir()` (`flasher.py`) | рядом с `main.py` | `sys.executable.parent / "custom_binaries"` | | `waiting._read_app_version()` | `tools/production/pyproject.toml` | тот же путь — `pyproject.toml` кладётся в `datas` спека (нужен для парсинга версии во frozen) |
| `main._setup_logging()` | рядом с `main.py` | рядом с исполняемым файлом (`sys.executable.parent`) |
`main.py::_setup_logging()` и `.env`-загрузка тоже различают режимы: `sys.executable` (не `sys._MEIPASS`) — единственный путь, одинаково
лог-файл во frozen пишется рядом с exe (не внутрь `_internal/`); `.env` во работающий и для onefile, и для onedir; `_MEIPASS` для onefile указывает на
frozen не подгружается вообще (frozen-сборка работает на fallback-константах временную распаковку, которая исчезает после выхода из процесса.
в коде, не полагаясь на файл, которого в бандле нет).
### 14.3 `service_tui.spec` — сборка (важные детали) Нативный HID-транспорт (`libusbsio`, следствие Р7 — spsdk вместо pyusb)
означает, что `libusb-1.0.*`/Zadig в бандле **не нужны** ни на Windows, ни
на macOS — детект BootROM SDP и Flashloader работает из коробки.
- **onedir, не onefile** — onefile ощутимо медленнее стартует (распаковка во > **Расхождение spec/факт:** закоммиченный `service_tui.spec` объявляет в
временную директорию при каждом запуске). > `datas` только `('../shared', 'shared')` — без `dcd/*.bin`,
- **`collect_data_files("spsdk")`** — обязателен, не перестраховка: ~380 > `pyproject.toml` или `spsdk`-данных. Тем не менее уже собранные релизные
файлов (`data/devices/*/database.yaml` и т.п.), которые реально резолвит > бандлы в `tools/production/dist/service-tui-v0.2.0-{macos,windows}/`
`HabImage`/`Config` для `family=mimxrt1050`. > фактически содержат `_internal/data/{dcd.bin,*_fdcb.bin,ivt_flashloader.bin}`,
- **`collect_dynamic_libs("libusbsio")`** — заберёт бинарники **всех** > `_internal/pyproject.toml` и `_internal/spsdk/` — то есть сборки, тестировавшиеся
поддерживаемых платформ (`bin/osx_arm64/`, `bin/x64/`, `bin/linux_*` и > на железе (Фаза 5, гейт по macOS/Windows), были собраны с более полным
т.д. — `rglob` без фильтра по текущей ОС). Не баг: сама `libusbsio.py` > набором `datas`, чем то, что сейчас лежит в репозитории. `service_tui.spec`
резолвит нужный файл в рантайме по `platform.system()`/`platform.machine()`, > нужно актуализировать (`collect_data_files("spsdk")`, `tools/host/dcd/*.bin`
лишние платформы просто раздувают бандл. При необходимости можно > → `data/`, `pyproject.toml`) до следующей сборки релиза — см. «Известные
отфильтровать под текущую ОС отдельно. > открытые вопросы».
- **`hiddenimports=["app", "app.app", "app.screens", "app.widgets"]`** —
явная подстраховка из-за отсутствия `__init__.py` в `app/` (см. §1).
Современный PyInstaller обычно справляется и без этого через анализ
импортов из `main.py`, но цена перестраховки нулевая.
- **`upx=False`** — сознательно, не дефолт PyInstaller: UPX-паковка вместе
с нативными HID-либами (libusbsio) — известный источник проблем с
загрузкой.
### 14.4 Известные грабли упаковки ---
- **Windows: `mv`/`rm -rf` в post-build шаге может упасть с ## 15. Логирование (Р11/Р12, Фаза 4b)
`Permission denied`**, если целевая директория из предыдущей сборки ещё
содержит заблокированный файл (например, `service_tui.exe` от прошлого `main.py::_setup_logging()`:
запуска, не закрытый перед повторной упаковкой, либо антивирус временно
удерживает хендл на свежесозданном `.exe`). Симптом: сообщение об ошибке - Root-логгер по умолчанию — `INFO` (не `DEBUG`); файл —
показывает путь **вложенным** (`dist/service-tui-vX.Y.Z-windows/service_tui`) `service_tui.log` рядом с исполняемым файлом (или `$SERVICE_LOG_DIR`).
— это Unix-семантика `mv` в существующую директорию, сигнал, что `rm -rf` - `SERVICE_LOG_LEVEL=DEBUG` включает полный DEBUG, **включая** портянки
не до конца очистил цель. Лечится закрытием запущенного exe перед повторной `spsdk`/`libusbsio` (сырые HID TX/RX-пакеты — ~135 строк на одну
упаковкой. прошивку).
- **`just` + bash-shebang рецепты на Windows** — на некоторых машинах поиск - При любом другом значении (или отсутствии переменной) логгеры
`bash` через PATH может резолвиться в `C:\Windows\System32\bash.exe` `spsdk`, `libusbsio`, `libusbsio.hidapi.dev`,
(WSL-заглушка) вместо Git Bash, если WSL сконфигурирован некорректно — `spsdk.mboot.protocol.bulk_protocol` принудительно приглушены до
проявляется как `WSL (...) ERROR: execve(/bin/bash) failed`. Специфично `WARNING`, независимо от уровня root — иначе диагностика `app.*`
для конкретной машины/PATH, не для рецепта — решается на уровне окружения тонет в чужом протоколе.
(порядок PATH, состояние WSL), не в `Justfile`. - `textual` отдельно всегда на `WARNING`.
`FlashScreen._on_progress()` (Р11) троттлит **только** запись в
`#flash-log` для фазы `write`: событие логируется раз на каждые 10%
(`progress.percent // 10`), а не на каждый пакет `spsdk` (~135 →
~10 строк). Прогресс-бар при этом обновляется на **каждом** событии —
плавность не теряется, троттлинг влияет только на текстовый лог.
--- ---
## Известные открытые вопросы ## Известные открытые вопросы
- **Release-сборка firmware нестабильна** : работает только с оптимизацией уровня O1 - **Документация — Фаза 6 (текущая).** Инженерные фазы 05 (backend на spsdk,
обработка обрыва USB, троттлинг логов, упаковка PyInstaller) закрыты в
коде; `docs/DEV_ARCH.md`/`README.md` актуализированы этой правкой. Осталось
по `RELEASE_ROADMAP.md` §Фаза 6: `CHANGELOG.md` (не заведён), grep-зачистка
устаревших docstring-упоминаний `flash_usb.py`/`subprocess` в
`app/flash.py` (комментарий `_check_sdp_present`) и `flasher.py`
(docstring модуля упоминает Фазу 2 буквально, что нормально как история
провенанса, но стоит перепроверить при следующей правке этих файлов).
- **`service_tui.spec` не актуализирован под реальные релизные сборки** —
см. §14. Нужно добавить `datas` (`dcd/*.bin`, `pyproject.toml`,
`collect_data_files("spsdk")`) до следующей упаковки релиза.
- **`pyusb` в `pyproject.toml` — мёртвая зависимость.** Р7 перевёл детект
SDP/CDC на `spsdk`/`serial.tools.list_ports`; ни один модуль `app/` больше
не импортирует `usb`/`pyusb`. Кандидат на удаление при следующей
grep-зачистке (Фаза 6).
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
форсирует Debug через `FIRMWARE_BUILD_TYPE`.
- **`tools/shared/m5_agent.py`** — сознательно не делался: pytest - **`tools/shared/m5_agent.py`** — сознательно не делался: pytest
HIL-окружение и TUI используют независимые M5-клиенты, признано правильным HIL-окружение и TUI используют независимые M5-клиенты, признано правильным
архитектурным решением, а не техдолгом. (Устаревшая `just host::service-build` архитектурным решением, а не техдолгом.
ссылается на несуществующий `tools/shared/` через `--add-data` — рецепт,
скорее всего, нерабочий, кандидат на удаление в пользу `package-tui`.)
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и - Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
«копирование UID с экрана» — отложены, не начаты. «копирование UID с экрана» — отложены, не начаты.
- **POST-1 (циклический прогон неинтерактивных тестов на DiagScreen)**
сознательно отложен на пост-релиз, вне `MONOLITH_APP_PLAN.md` (см.
`RELEASE_ROADMAP.md`).
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту - **Массовое программирование** — решено НЕ делать авто-прошивку по факту
детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем детекта SDP (см. §8.2); ограничились «липким» `FlashPreset`. Если в будущем
понадобится полный батч-режим — потребуется отдельный предохранитель понадобится полный батч-режим — потребуется отдельный предохранитель
(задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя (задержка с отменой перед стартом), т.к. в SDP-режиме плату нельзя
идентифицировать по UID. идентифицировать по UID. Связанное ограничение v1 (О3) — предполагается
ровно одна плата на столе одновременно (см. README).
- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили - **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили
не полагаться на него вообще, для кастомных бинарей FCB всегда пишется не полагаться на него вообще, для кастомных бинарей FCB всегда пишется
явно (§8.4). Остаётся не до конца понятым, работает ли явно (параметр `fcb_path` в `flash_backend.flash()`, см. §8.4). Остаётся не
`configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос до конца понятым, работает ли `configure-memory 0xF000000F` для этих
снят с повестки архитектурным решением, а не исследован до конца. чипов корректно в принципе — вопрос снят с повестки архитектурным
решением, а не исследован до конца.

View file

@ -0,0 +1,467 @@
# service-tui — Roadmap до релиза v1 (фазы 4a → 4b → 5 → 6)
> Продолжение `MONOLITH_APP_PLAN.md` (V4). Фазы 03 закрыты, Фаза 4
> закрыта частично — деструктивные гейты на железе вскрыли пробел в
> типизации обрыва USB (см. Фаза 4a). Этот документ — дорожная карта
> оставшегося пути до релиза.
>
> Ветка: `feature-tui-monolith` от `dev`. Каждая фаза = коммит(ы) с
> зелёным гейтом; откат любой фазы не ломает предыдущие.
---
## Статус на входе
| Фаза | Статус |
| --- | --- |
| 0 — Спайк / де-риск | ✅ Закрыта (⚠В1/⚠В2/Р7/О1 сняты, golden-тест byte-exact) |
| 1 — Backend-модуль | ✅ Закрыта (`flash_backend.py`, `usb_ports.py`, 41 тест) |
| 2 — Пересадка `Flasher` | ✅ Закрыта (async-мост, гейт на железе) |
| 3 — Custom HAB in-process | ✅ Закрыта (де-факто вместе с фазой 1, подтверждена на железе) |
| 4 — Обработка отвала USB | ⚠️ **Частично** — see 4a |
| 4a — Добор типизации обрыва | ⏳ **Следующая** |
| 4b — Сокращение логов | ⏳ |
| 5 — Упаковка PyInstaller | ⏳ |
| 6 — Документация / релиз | ⏳ |
### Почему Фаза 4 не закрыта
Деструктивные гейты на железе (macOS) показали: **выдёргивание USB
проявляется тремя разными способами**, а код Фазы 4 корректно
типизирует только один.
| Проявление в spsdk | Что реально прилетает | Фаза 4 (сейчас) | Пользователь видит |
| --- | --- | --- | --- |
| write-фаза, обрыв при записи | `SPSDKConnectionError` | обёрнут только вокруг `with SDP`/`with McuBoot`, но реальный путь `write_memory` прошёл мимо | «Непредвиденная ошибка» (safety net) |
| read-фаза после write | `SPSDKTimeoutError` (потомок `SPSDKError`, **не** `SPSDKConnectionError`) | не ловится обёрткой обрыва | «Непредвиденная ошибка» (safety net) |
| chip erase по таймауту | `flash_erase_all()` вернул `False` (без исключения) | ветка `if not ok: raise FlashBackendError(...)` | «flash_erase_all вернул False» |
План Фазы 4 буквально требовал «`McuBootConnectionError`/**таймауты** →
`FlashBackendError(connection_lost=True)`». Таймаут (`SPSDKTimeoutError`)
и `False`-по-таймауту не были покрыты — значит Гейт 4 по букве не пройден.
Это добор, а не новая работа сверх плана.
> **Важно (UX-надёжность уже работает):** safety net (`except Exception`
> в `flasher.py`) во всех трёх случаях корректно вернул `ok=False`,
> разблокировал кнопки, оставил приложение живым. Проблема
> исключительно в *формулировке* сообщения, не в устойчивости.
---
## Принятые решения этого этапа
| ID | Решение |
| --- | --- |
| **Р10** | Erase-таймаут (`False` без исключения) переклассифицируется в `ConnectionLostError` **вариантом B**: после `False` выполнить быстрый `detect_sdp()` — если устройство пропало с шины, это обрыв; если на месте — честная ошибка операции. |
| **Р11** | Троттлинг `write`-событий в `#flash-log` — каждые **10%** (≈10 строк вместо ~135). Прогресс-бар обновляется на **каждом** событии (плавность не теряется), в лог пишется прореженно. |
| **Р12** | Логирование: root/`spsdk`/`libusbsio` понижаются до `WARNING` по умолчанию; полный DEBUG доступен через env-переключатель (диагностика не теряется совсем). |
| **О2 (закрыт)** | Состав `firmware/` в релизе — только `firmware_test`; тип сборки (Debug/Release) управляется через `.env` (`FIRMWARE_BUILD_TYPE`), механизм уже реализован в `flasher.py`. |
| **POST-1** | Циклический прогон неинтерактивных тестов (SDRAM/NOR/OPTO/CAN) на `DiagScreen`**отложен на пост-релиз**, вне `MONOLITH_APP_PLAN.md`. Зафиксирован, чтобы не потерять. |
| **Предложение 2** | Кнопка «Выйти из приложения» на `WaitingScreen` — принято, включается в Фазу 5 (UI-полировка перед упаковкой). |
---
## Фаза 4a — Добор: корректная типизация обрыва USB
**Цель:** все три проявления обрыва USB дают пользователю единое
понятное сообщение «Соединение с платой потеряно», а не «Непредвиденная
ошибка» / «flash_erase_all вернул False».
### Файлы
| Файл | Тип правки |
| --- | --- |
| `app/flash_backend.py` | правки: расширить обёртку обрыва на `SPSDKTimeoutError`; реализовать вариант B для erase |
| `tests/test_flash_backend.py` | правки: тесты на `SPSDKTimeoutError`-путь и на erase-переклассификацию |
### Содержание
1. **Обёртка обрыва расширяется** с `SPSDKConnectionError` на общий
родительский случай, покрывающий и `SPSDKTimeoutError`. Оба
потомки `SPSDKError`, но `SPSDKTimeoutError` **не** наследует
`SPSDKConnectionError`, поэтому текущий `except SPSDKConnectionError`
его пропускает. Ловим оба явным кортежем
`(SPSDKConnectionError, SPSDKTimeoutError)` в трёх местах:
`load_flashloader`, `flash` (основная + ram_only ветки), `erase_chip`.
2. **Вариант B для erase** (Р10): в `erase_chip` (и в `flash`, где
команды возвращают `False` по тем же причинам) — при `ok == False`
выполнить быстрый `detect_sdp()`; если устройство исчезло с шины →
`ConnectionLostError`, иначе → обычный `FlashBackendError` с прежним
текстом. Проверка `detect_sdp()` добавляется **только в error-путь**,
на happy path не влияет.
3. **`_format_error_message` в `flasher.py` не трогается** — он уже
корректно даёт префикс «Соединение с платой потеряно» для любого
`connection_lost=True`. Достаточно, чтобы backend правильно поднял
`ConnectionLostError`.
### Гейт 4a
- [ ] Юнит-тест: `SPSDKTimeoutError` из `write_memory``ConnectionLostError`
(мок).
- [ ] Юнит-тест: `flash_erase_all``False` + `detect_sdp()==False`
`ConnectionLostError`; `False` + `detect_sdp()==True`
обычный `FlashBackendError` (мок).
- [ ] Существующие 41 тест зелёные (регрессии нет).
- [ ] **Железо (повтор деструктивных сценариев):**
- [ ] Выдернуть USB во время `write-memory` → в `#flash-log`
«Соединение с платой потеряно», не «Непредвиденная ошибка».
- [ ] Выдернуть во время chip erase → то же сообщение.
- [ ] Повторная вставка → прошивка успешна (порт не «занят»).
- [ ] macOS + Windows.
---
## Фаза 4b — Сокращение логов
**Цель:** лог-файл прошивки уменьшается на порядок; `#flash-log` в TUI
показывает осмысленный прогресс, а не ~135 однотипных строк.
### Файлы
| Файл | Тип правки |
| --- | --- |
| `app/main.py` | правки: уровни логгеров (Р12) + env-переключатель DEBUG |
| `app/screens/flash.py` | правки: троттлинг `write`-событий в `_on_progress` (Р11) |
### Содержание
1. **Уровни логгеров (Р12):** root по умолчанию `INFO`; `spsdk`,
`libusbsio`, `libusbsio.hidapi.dev`, `spsdk.mboot.protocol.bulk_protocol`
`WARNING` (именно они дают портянки HID-байтов). Полный DEBUG
включается через переменную окружения (например
`SERVICE_LOG_LEVEL=DEBUG`) — точное имя согласовать, не изобретаю.
2. **Троттлинг `#flash-log` (Р11):** в `flash.py::_on_progress`
прогресс-бар обновляется всегда, а `write_line` в лог для фазы
`write` — только при пересечении 10%-границы (0/10/20/…/100).
Остальные фазы (`configure`/`erase`/`fcb`/`reset`/`done`/`error`/
`hab_build`) логируются как есть — их немного.
### Гейт 4b
- [ ] Лог-файл одной прошивки на порядок короче; DEBUG-портянок
spsdk/libusbsio нет при дефолтном уровне.
- [ ] `SERVICE_LOG_LEVEL=DEBUG` (или согласованное имя) возвращает
полный DEBUG — диагностика доступна.
- [ ] `#flash-log`: ~10 строк прогресса записи вместо ~135, бар
по-прежнему плавный.
- [ ] Регрессия: прошивка/erase/диагностика на железе работают.
---
## Фаза 5 — Упаковка PyInstaller + UI-полировка
**Цель:** один исполняемый бандл на Windows и macOS, проходящий полный
полевой цикл на чистой машине без Zadig/сети/Python. Плюс кнопка
«Выйти» на `WaitingScreen`.
### Файлы
| Файл | Тип правки |
| --- | --- |
| `tools/production/service_tui.spec` | новый — PyInstaller spec |
| `app/screens/waiting.py` | правки: кнопка «Выйти из приложения» (Предложение 2) |
| just-рецепт | новый — имя задачи согласовать, **не изобретаю** |
| `app/app.tcss` | правки при необходимости — стиль кнопки Quit на Waiting |
### Содержание spec (из плана V4, §Фаза 5)
- `collect_data_files("spsdk")` (+ `SPSDK_DATA_FOLDER` при необходимости —
документированный NXP механизм для frozen);
- **`collect_dynamic_libs("libusbsio")`** — нативный HID-транспорт
(следствие Р7); `libusb-1.0.*` в бандле **отсутствует**;
- `datas`: `tools/host/dcd/{dcd.bin, w25q128_fdcb.bin, w25q512_fdcb.bin,
ivt_flashloader.bin}` → `data/`;
- `datas`: `firmware/<Type>/firmware_test_hab.bin` (Type из `.env`, О2);
- `datas`: `pyproject.toml` (для `_read_app_version` во frozen);
- onedir (не onefile — onefile замедляет старт распаковкой);
- резолвер путей backend'а уже готов: frozen → `sys.executable.parent`
(`firmware_hab_path`, `_resolve_custom_binaries_dir`).
Целевая структура бандла:
```
service-tui-vX.Y.Z-<os>/
├── service_tui[.exe]
├── _internal/
│ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data
│ └── ... ← рантайм PyInstaller, libusbsio
├── firmware/
│ └── <Type>/firmware_test_hab.bin
└── custom_binaries/ ← пустая
```
### UI-полировка (Предложение 2)
Кнопка «✕ Выйти из приложения» на `WaitingScreen`, симметрично
`FlashScreen`/`DiagScreen`/`PostFlashScreen` (`self.app.exit()`).
### Гейт 5 (Windows + macOS)
- [ ] Чистая Windows, **без Zadig, без сети, без Python/uv**: полный
полевой цикл — детект SDP → firmware_test → диагностика →
custom (W25Q128 и W25Q512) → chip erase.
- [ ] То же на macOS.
- [ ] Версия на `WaitingScreen` корректна во frozen.
- [ ] Порты резолвятся при перетыкании в другой физический USB-порт
(проверка Р8 на обеих ОС).
- [ ] Кнопка «Выйти» на `WaitingScreen` работает.
- [ ] M5StampPLC (нативный CDC `303A:4001`, драйверы не нужны —
подтверждено О1) виден во frozen-бандле.
---
## Фаза 6 — Документация, CHANGELOG, финальная зачистка, релиз
**Цель:** синхронизировать документацию с реальностью монолита,
провести отложенную зачистку комментариев/grep, собрать релизный
артефакт из тега.
### Файлы
| Файл | Тип правки |
| --- | --- |
| `app/flash_backend.py`, `app/flasher.py`, `app/screens/flash.py`, `app/models.py` | правки: **отложенная зачистка комментариев** (grep-cleanup Гейта 4 + актуализация docstring-провенансов) |
| `CHANGELOG.md` | правки |
| `RELEASE_PLAN.md` | правки: закрыть шаг 3 ссылкой на V4/этот roadmap |
| `docs/DEV_ARCH.md` | правки: §2 (убрать subprocess из диаграммы), §8.3 (новый конвейер) |
| `HOW_TO_FLASH.md` | правки |
| `tools/production/README.md` | правки |
| `.env.example` | правки: по Р9 (+`SERVICE_M5_VID/PID`, `FIRMWARE_BUILD_TYPE`, `SERVICE_LOG_LEVEL`) |
### Содержание
1. **Отложенная зачистка (из Фазы 4, согласовано):** финальный проход
по всему коду — актуализировать docstring-провенансы («прямой порт
flash_usb.py», «subprocess-версия» и т.п.) под реальность монолита.
Цель grep Гейта 4 (`flash_usb\|uv run\|subprocess\|usb.core` пусто
в `app/`) — либо достигается, либо остаётся осознанно как
документация происхождения (решение по каждому вхождению).
2. **CHANGELOG:** монолит (flash_backend, отказ от venv/subprocess),
нативный детект без Zadig (Р7), кроссплатформенный резолв портов (Р8),
нативная обработка отвала USB, упаковка одним exe.
3. **Zadig-инструкция в доки НЕ добавляется** (Р7 отменил план
RELEASE_PLAN). M5 — нативный CDC, вендорский драйвер не нужен (О1).
4. **Разделение зафиксировать:** `flash_usb.py` — dev-CLI (just-рецепты),
`flash_backend.py` — production-TUI; независимые реализации (Р2).
5. **Golden-тест HAB** — отметить как обязательный при апгрейде spsdk.
6. **Ограничение «одна плата на столе»** (О3) — в README.
7. **POST-1** (циклический прогон) — зафиксировать в бэклоге/README как
запланированную пост-релизную фичу.
8. Тег релиза = версия из `pyproject.toml`.
### Гейт 6
- [ ] Документация синхронизирована (железо подтверждено гейтами 4a/5).
- [ ] `just host::flash*`, `incoming`, `production` работают как раньше —
регрессия dev-пути.
- [ ] Релизный артефакт собран из тега; чек-лист Гейта 5 повторён на
релизном бинаре.
- [ ] POST-1 зафиксирован в бэклоге, не потерян.
---
## Сводная последовательность и зависимости
```
4a ──► 4b ──► 5 ──► 6 ──► RELEASE v1
│ │ │ │
│ │ │ └── доки, зачистка, тег, регрессия dev-пути
│ │ └── PyInstaller (Win+macOS), кнопка Quit на Waiting
│ └── уровни логов + троттлинг #flash-log
└── типизация обрыва (SPSDKTimeoutError + erase вариант B)
Блокеры перед фазами:
4a: нет — старт сразу
4b: нет — после 4a
5: О2 закрыт ✅; согласовать имя just-задачи и env-переменной DEBUG
6: все гейты 4a/4b/5 зелёные на железе
```
## Открытые мелочи (согласовать по ходу, не блокируют старт 4a)
| Вопрос | Когда нужен | Предложение |
| --- | --- | --- |
| Имя env-переменной уровня лога | Фаза 4b | `SERVICE_LOG_LEVEL` (в стиле существующих `SERVICE_*`) |
| Имя just-задачи упаковки | Фаза 5 | согласовать по `Justfile`, не изобретаю |
| Формат имени релизного каталога | Фаза 5 | `service-tui-vX.Y.Z-<os>` (из плана) |
---
## Риски этого этапа
| Риск | Фаза | Митигация |
| --- | --- | --- |
| `detect_sdp()` в error-пути erase сам упадёт/подвиснет (шина уже нестабильна) | 4a | обернуть проверку в try/except, при любой ошибке — считать «устройство пропало» (обрыв); проверка уже в error-пути, хуже не сделает |
| Троттлинг скроет полезную деталь при отладке | 4b | полный DEBUG остаётся через env-переключатель |
| PyInstaller не соберёт нативные libusbsio / data spsdk | 5 | документированный NXP путь (`collect_dynamic_libs`, `SPSDK_DATA_FOLDER`); риск на CI, не в поле |
| frozen-резолв путей разойдётся с onedir-структурой | 5 | резолвер уже написан и покрыт тестом `test_firmware_hab_path_frozen` |
| Регрессия dev-пути (`just host::flash*`) после зачистки | 6 | `flash_usb.py` не трогался ни в одной фазе (Р2); гейт 6 это проверяет |
---
# Приложение: работа в новом треде
Этот roadmap рассчитан на продолжение в **новом чате без контекста**
предыдущего. Ниже — всё, что нужно передать вместе с этим файлом, чтобы
новый тред стартовал без потерь.
## A. Какой набор правил к чему применяется
Проектные правила «Role & Hardware Context» (senior embedded C, i.MX
RT1052, LVGL, SDK HAL, C11, Doxygen, `.clang-tidy`/`.clang-format`,
CMake) написаны под **C/прошивочную** часть монорепо (`firmware_test`).
**Вся работа этого roadmap (4a→4b→5→6) — Python/spsdk/Textual** в
`tools/production`. Поэтому:
| Правило | Применимо к Python-работе roadmap? |
| --- | --- |
| Unified diffs, не полные переписывания | ✅ Да |
| «Какой файл / какая функция затронуты» — первым | ✅ Да |
| ASK при неоднозначности/противоречии | ✅ Да |
| Не изобретать just-таски / пути / структуру | ✅ Да |
| Проверять существующие файлы перед правкой | ✅ Да |
| No malloc/free в драйверах и ISR | ❌ C-специфично |
| NXP SDK HAL вместо raw-регистров | ❌ C-специфично |
| Doxygen на public API | ❌ (Python — docstrings, уже используются) |
| `.clang-tidy`/`.clang-format` | ❌ (Python — стиль проекта: type hints, `from __future__ import annotations`) |
| CMake target_compile_options | ❌ Неприменимо |
Когда/если roadmap коснётся C-части — C-правила снова в силе.
## B. Первый вопрос на старте нового треда (не потерять)
**Фаза 4a, вариант B (Р10):** `detect_sdp()` в error-пути `erase_chip`
предлагается обернуть в `try/except`, и **любую ошибку самой проверки**
(не только «устройство отсутствует») трактовать как обрыв — потому что
проверка и так выполняется только после уже случившегося сбоя, шина
нестабильна, и «не смог проверить» практически всегда означает «платы
нет». Требуется явное подтверждение этой трактовки перед написанием
кода Фазы 4a. (Альтернатива: ошибка самой проверки → обычный
`FlashBackendError`.)
## C. Файлы, которые нужно предоставить — по фазам
Пути относительно `tools/production/`, если не указано иное. Пометка
**[есть в этом треде]** — файл уже фигурировал и его актуальная версия
известна; в новом треде его всё равно нужно приложить заново.
### Фаза 4a — типизация обрыва
| Файл | Зачем |
| --- | --- |
| `app/flash_backend.py` **[правится]** | основной файл фазы — обёртки обрыва + вариант B |
| `tests/test_flash_backend.py` **[правится]** | новые тесты на `SPSDKTimeoutError` и erase-переклассификацию |
| `app/flasher.py` | контекст: `_format_error_message` / `_run_flash_op` — убедиться, что `connection_lost` доходит до UI (не факт что правится) |
| `app/models.py` | контекст: `FlashProgress` |
### Фаза 4b — логи
| Файл | Зачем |
| --- | --- |
| `app/main.py` **[правится]** | уровни логгеров + env-переключатель DEBUG (Р12) |
| `app/screens/flash.py` **[правится]** | троттлинг `#flash-log` в `_on_progress` (Р11) |
| `.env` / `.env.example` (`tools/production/`) | согласовать имя `SERVICE_LOG_LEVEL` с существующими переменными |
### Фаза 5 — упаковка PyInstaller + UI
| Файл | Зачем |
| --- | --- |
| `pyproject.toml` (`tools/production/`) | зависимости, версия, `requires-python` — база для spec |
| `Justfile` + все `*.just` (корневой и подключаемые: `build.just`, `ci.just`, `host.just`) | **согласовать имя задачи упаковки, НЕ изобретать** — критично по правилу проекта |
| `app/main.py` | entry point для PyInstaller |
| `app/app.py` | `CSS_PATH="app.tcss"` — как резолвится во frozen |
| `app/app.tcss` | data-файл для бандла; правки под кнопку Quit |
| `app/screens/waiting.py` **[правится]** | кнопка «Выйти» (Предложение 2) |
| `app/flasher.py`, `app/flash_backend.py` | frozen-резолв путей (`firmware_hab_path`, `_resolve_custom_binaries_dir`) — проверить против структуры бандла |
| дерево `tools/host/dcd/` (список файлов) | что кладём в `datas` (`dcd.bin`, `*_fdcb.bin`, `ivt_flashloader.bin`) |
| `project_tree.txt` или `ls -R tools/production` | реальная структура пакета `app/` для spec |
| существующий `.spec`, если уже есть | не изобретать заново |
### Фаза 6 — документация и релиз
| Файл | Зачем |
| --- | --- |
| `CHANGELOG.md` | дописать секцию монолита |
| `RELEASE_PLAN.md` | закрыть шаг 3 ссылкой на этот roadmap |
| `docs/DEV_ARCH.md` | §2 (диаграмма без subprocess), §8.3 (новый конвейер) |
| `HOW_TO_FLASH.md` | актуализировать под TUI-backend |
| `tools/production/README.md` | ограничение О3, POST-1, разделение dev-CLI / production-TUI |
| `.env.example` | по Р9 (+`SERVICE_M5_VID/PID`, `FIRMWARE_BUILD_TYPE`, `SERVICE_LOG_LEVEL`) |
| `app/flash_backend.py`, `app/flasher.py`, `app/screens/flash.py`, `app/models.py` | финальная зачистка комментариев (grep-cleanup Гейта 4) |
| `tools/host/flash_usb.py` | сверка при зачистке — что dev-CLI и правда не тронут (Р2) |
## D. Полный список актуальных файлов монолита (снимок на входе)
Чтобы в новом треде можно было приложить всё разом, если удобнее не
дробить по фазам. Актуальные (пост-Фаза-4) версии:
```
tools/production/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── app.py
│ ├── app.tcss
│ ├── main.py (точка входа — фактически в tools/production/main.py, см. pyproject scripts)
│ ├── models.py
│ ├── flasher.py ← Фаза 2/4, актуальная версия
│ ├── flash_backend.py ← Фаза 1/4, актуальная версия (41 тест)
│ ├── usb_ports.py ← Фаза 1
│ ├── firmware_client.py
│ ├── m5_client.py
│ ├── orchestrator.py
│ ├── boot_art.py
│ ├── widgets.py (или widgets/)
│ └── screens/
│ ├── __init__.py
│ ├── waiting.py
│ ├── flash.py ← Фаза 4 (правлены комментарии)
│ ├── post_flash.py
│ ├── connection_watcher.py
│ └── diag/
│ ├── __init__.py
│ ├── confirm_panel.py
│ ├── results.py
│ └── test_list.py
├── tests/
│ ├── __init__.py
│ └── test_flash_backend.py ← 41 тест
├── spike/ (Фаза 0, в релиз не идёт)
│ ├── spike_hab.py
│ ├── spike_flash.py
│ └── spike_readback.py (диагностика Гейта 3, на будущее)
└── custom_binaries/ (пустая, для оператора)
tools/host/ (dev-CLI, Р2 — НЕ трогается)
├── flash_usb.py
└── dcd/
├── ivt_flashloader.bin
├── dcd.bin
├── w25q128_fdcb.bin
└── w25q512_fdcb.bin
```
> Примечание: `main.py` в `pyproject.toml` прописан как
> `service-tui = "main:main"` — точка входа лежит в
> `tools/production/main.py` (не в `app/`), а `app/app.py` содержит
> `ServiceApp`. Уточнить фактическое расположение при старте Фазы 4b/5.
## E. Что уже решено и не пересматривается (сводка для нового треда)
- **Р1Р9** — см. `MONOLITH_APP_PLAN.md` (приложить его тоже).
- **Р10** — erase-таймаут → вариант B (detect_sdp после False).
- **Р11** — троттлинг лога 10%.
- **Р12** — уровни логов + env DEBUG.
- **О1** — M5 = нативный CDC `303A:4001`, драйверы не нужны.
- **О2**`firmware/` = только firmware_test, тип сборки через `.env`.
- **О3** — одна плата на столе, ограничение v1.
- **POST-1** — циклический прогон тестов, после релиза.
- Публичный API `Flasher` заморожен; `flash.py`/`waiting.py`/`app.py`
меняются только там, где явно указано в roadmap.
- `flash_usb.py` (dev-CLI) не трогается ни в одной фазе.
- Порядок ревью: один файл за раз, полные файлы для новых/целиком
переписываемых, unified diff для точечных правок.

View file

@ -329,7 +329,6 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path):
with pytest.raises(fb.FlashBackendError, match="write_memory"): with pytest.raises(fb.FlashBackendError, match="write_memory"):
fb.flash(hab_bin, progress_cb=_collector(events)) fb.flash(hab_bin, progress_cb=_collector(events))
# reset/done не должны наступить после ошибки записи
assert "reset" not in _phases(events) assert "reset" not in _phases(events)
assert "done" not in _phases(events) assert "done" not in _phases(events)