# Refactor docs

This commit is contained in:
Dmitry Akimov 2026-07-07 10:25:49 +03:00
parent 2dbe3e61ed
commit 22c40779ef
4 changed files with 880 additions and 805 deletions

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
> Диагностическая прошивка для плат **MIMXRT1052CVJ5B**, вернувшихся по
> рекламации. Запускается сервисным инженером через USB CDC ACM без
> предварительной прошивки загрузчика.
>
> Версия прошивки: `0.1.0` | Протокол: v2
> Диагностическая прошивка для плат TFT индикаторов, вернувшихся по
> рекламации. Загружается на таргет сервисным инженером через USB (через BootROM IMXRT1052).
>
>Версия прошивки: `0.1.2` | Протокол: v2
>
>Версия — из `project(firmware_test VERSION X.Y.Z)` в `CMakeLists.txt`
> (см. [Версионирование](#версионирование))
---
@ -27,6 +29,7 @@
- [Как добавить новый тест](#как-добавить-новый-тест)
- [Host unit-тесты](#host-unit-тесты)
- [Версионирование](#версионирование)
- [Архитектурные решения (закрыты)](#архитектурные-решения-закрыты)
---
@ -45,7 +48,7 @@ just build::hab-firmware-test-debug
# Перевести плату в SDP-режим: BOOT_MOD_1 → 3V3 → Reset
just host::flash-test-debug
# Или через SWD (power cycle после)
# Или через SWD
just host::flash-swd-test-debug
```
@ -66,7 +69,7 @@ screen /dev/ttyACM0
После подключения таргет сразу присылает:
```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
→ {"type":"cmd","cmd":"run","id":"sdram"}
← {"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 сообщение
[Плата 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 тестов]
(реле → 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`).
- Хост — тонкий клиент: отправляет команды, отображает события, управляет
интерактивными шагами через `confirm`.
- Тесты **атомарны**: инженер запускает один тест или все сразу — порядок
не фиксирован.
- Тесты **атомарны**: инженер запускает один тест, подмножество или все сразу —
порядок не фиксирован.
---
@ -136,6 +139,8 @@ firmware/test/
└── src/
├── main.c — инициализация BSP, главный цикл
├── version.h.in — шаблон версии (CMake → generated/version.h)
├── cli.h / cli.c — IO-слой
│ буферизация строк, парсинг "type",
│ диспатч на test_runner / protocol
@ -149,20 +154,23 @@ firmware/test/
├── test_runner.h / .c — реестр + state machine
│ IDLE → PRE_CONFIRM → RUNNING → IDLE
│ test_runner_wait_confirm() для display
│ test_runner_wait_confirm() для in-run confirm
└── tests/
├── test_sdram.c — SDRAM 32 MB (self)
├── test_qspi.c — QSPI Flash 8 MB (self)
├── test_usd.c — uSD SDIO (interactive)
├── test_display.c — Display RGB888 (interactive)
├── test_buttons.c — Test_But_1/2 (interactive)
├── test_qspi.c — QSPI Flash W25Qxx (self)
├── test_usd.c — microSD SDIO (interactive, pre-confirm)
├── test_display.c — Display RGB888 (interactive, in-run confirm)
├── 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_uart_ttl.c — UART TTL (HIL)
├── test_uart_iso.c — UART ISO / RS_RX Opto (HIL)
└── test_opto.c — Opto-in EXT_IN1/IN2 (HIL)
└── test_mqs.c — MQS Audio Out (interactive, in-run confirm)
```
> Порядок файлов в `tests/` — как в `CMakeLists.txt`. Порядок **выполнения**
> тестов определяется реестром `k_registry[]` в `test_runner.c`
> (см. [Матрица тестов](#матрица-тестов)).
---
### Граф зависимостей
@ -175,8 +183,9 @@ main.c
├── bsp_usb_cdc (USB CDC ACM, единственный транспорт)
├── cli.c
│ └── bsp_usb_cdc (read / write)
│ └── protocol.c (send_error, send_pong)
│ └── test_runner.c (run_single, run_all, on_confirm)
│ └── bsp_provisioning (bsp_prov_read_uid — для get_uid)
│ └── 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
│ └── cli.c (cli_send)
│ └── bsp_tick (bsp_tick_get_ms — для uptime)
@ -188,26 +197,28 @@ main.c
└── tests/*.c (тест-модули через реестр)
```
**BSP-зависимости тест-модулей:**
**BSP-зависимости тест-модулей** (по `target_link_libraries` в `CMakeLists.txt`):
| Тест | BSP модуль |
| --------------- | ------------------------------ |
| `test_sdram` | `bsp_sdram` |
| `test_qspi` | `bsp_qspi` |
| `test_usd` | `bsp_usd` |
| `test_display` | существующий display BSP |
| `test_buttons` | `bsp_button` ✅ |
| `test_can` | `bsp_can` ✅ |
| `test_uart_ttl` | `bsp_uart_host` ✅ |
| `test_uart_iso` | `bsp_opto` (rs_as_gpio=true) ✅ |
| `test_opto` | `bsp_opto` ✅ |
| Тест | BSP модуль |
| -------------- | ------------------------------ |
| `test_sdram` | `bsp_sdram` |
| `test_qspi` | `bsp_qspi_flash` |
| `test_usd` | `bsp_sd` (+ `firmware_test_fatfs`) |
| `test_display` | `bsp_display` |
| `test_buttons` | `bsp_button` |
| `test_opto` | `bsp_opto` (rs_as_gpio=true) |
| `test_can` | `bsp_can` |
| `test_mqs` | `bsp_mqs` |
> `bsp_uart_host` также линкуется (используется вне тест-реестра); отдельного
> UART-тест-модуля в текущем реестре нет (тестируется в `tests/target`).
---
### State machine test_runner
```bash
cmd: run / run_all
cmd: run / run_all / run_selected
┌─────────────────────────────────────┐
@ -232,9 +243,9 @@ main.c
confirmed=false → SKIP │ │
timeout → SKIP │ │
│ │
run: IDLE ──┘ │
run_all: следующий тест в реестре ───┘
run_all done: protocol_send_summary()
run: IDLE ──┘ │
run_all / run_selected: следующий тест ───┘
done: protocol_send_summary()
```
**Ключевые свойства state machine:**
@ -242,10 +253,12 @@ main.c
- `RUNNING` — защита от ложного `is_busy()==false` во время blocking `run()`.
Пока тест выполняется, новые команды получают `BUSY`.
- `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 приходит без возврата в главный цикл.
- `critical=true` + `FAIL` в `run_all` → все оставшиеся тесты получают
`SKIP` немедленно, `summary.overall = "fail"`.
- `run_selected` работает по той же машине над маской выбранных тестов
(`g_s_selected[]`); порядок — по реестру, не по порядку в запросе.
- `critical=true` + `FAIL` в `run_all`/`run_selected` → все оставшиеся тесты
получают `SKIP` немедленно, `summary.overall = "fail"`.
---
@ -258,7 +271,7 @@ main.c
| Интерфейс | USB CDC ACM, разъём J2 |
| Кодировка | UTF-8 |
| Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` |
| Максимальная длина строки | 128 байт включая `\n` |
| Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) |
| CR+LF | Принимается (таргет отбрасывает `\r`) |
Нет хэндшейка, нет sequence number, нет подтверждений доставки.
@ -273,7 +286,7 @@ main.c
│ │
│ [USB SDP: прошивка загружена] │
│ [CDC ACM: порт открыт] │
│◄─── {"type":"session_start","fw":"0.1.0",...} │ автоматически
│◄─── {"type":"session_start","fw":"0.1.2",...} │ автоматически
│ │
│──── {"type":"cmd","cmd":"ping"} ─────────────►│
│◄─── {"type":"pong"} │
@ -290,7 +303,8 @@ main.c
```
`session_start` отправляется **автоматически** при каждом старте, до получения
первой команды. Хост должен быть готов принять его сразу после открытия порта.
первой команды. Хост должен быть готов принять его сразу после открытия порта
(либо не полагаться на него — фикстуры HIL проверяют живость через `ping`).
---
@ -310,7 +324,7 @@ main.c
```json
→ {"type":"cmd","cmd":"run","id":"sdram"}
← {"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` не найден:
@ -324,13 +338,58 @@ main.c
```json
→ {"type":"cmd","cmd":"run_all"}
← {"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_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"}
```
#### `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` — ответ оператора на интерактивный шаг
```json
@ -347,35 +406,19 @@ main.c
#### `session_start`
```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`
```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`
```json
{
"type": "test_result",
"id": "sdram",
"status": "pass",
"ms": 312,
"detail": ""
}
{ "type":"test_result", "id":"sdram", "status":"pass", "ms":15304, "detail":"" }
```
| `status` | Смысл |
@ -384,37 +427,39 @@ main.c
| `"fail"` | Тест провален; `detail` содержит описание (до 95 символов) |
| `"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`
```json
{
"type": "confirm_request",
"id": "display_red",
"prompt": "Экран залит красным цветом?",
"timeout_ms": 15000
}
{ "type":"confirm_request", "id":"display_red", "prompt":"Screen is solid red?", "timeout_ms":15000 }
```
Хост отображает `prompt` оператору. Авторитетный таймаут — на таргете.
Хост может дублировать countdown для UX.
#### `uid_response` / `version_response`
Ответы на `get_uid` / `get_version` (см. соответствующие команды выше).
#### `summary`
```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":"pass"` — все `critical` тесты прошли (non-critical могут fail).
#### `pong`
Ответ на `ping`: `{"type":"pong"}`.
---
### Ошибки протокола
@ -425,43 +470,60 @@ main.c
← {"ok":false,"error":"UNKNOWN_TEST"} — "id" не найден в реестре
← {"ok":false,"error":"LINE_TOO_LONG"} — строка превысила 128 байт
← {"ok":false,"error":"BUSY"} — таргет выполняет тест
← {"ok":false,"error":"UID_READ_ERR"} — bsp_prov_read_uid() вернул ошибку
```
---
### Интерактивные тесты
#### uSD — вставить карту
#### microSD — вставить карту (pre-confirm)
```bash
← {"type":"confirm_request","id":"usd_insert","prompt":"Вставьте microSD","timeout_ms":30000}
→ {"type":"confirm","id":"usd_insert","confirmed":true}
← {"type":"confirm_request","id":"usd","prompt":"Insert microSD card and press OK","timeout_ms":30000}
→ {"type":"confirm","id":"usd","confirmed":true}
← {"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
← {"type":"test_result","id":"usd","status":"skip","ms":0,"detail":"operator skipped"}
```
#### Display RGB888 — подтвердить цвета и ротацию (in-run confirm)
#### Display RGB888 — подтвердить цвета
Четыре шага R/G/B/W. Итог — AND всех подтверждений.
Шесть шагов: Red → Green → Blue → White, затем два ротационных
(диагностика непропаянных LR/UD пинов). Тест прерывается на **первом**
неподтверждённом шаге (короткое замыкание, не сбор всех ответов).
```bash
← {"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_request","id":"display_green",...}
→ {"type":"confirm","id":"display_green","confirmed":true}
← {"type":"confirm_request","id":"display_blue",...}
→ {"type":"confirm","id":"display_blue","confirmed":true}
← {"type":"confirm_request","id":"display_white",...}
→ {"type":"confirm","id":"display_white","confirmed":false}
← {"type":"test_result","id":"display","status":"fail","ms":22103,
"detail":"display_white not confirmed"}
→ {"type":"confirm","id":"display_white","confirmed":true}
← {"type":"confirm_request","id":"display_rot0","prompt":"Screen: left RED, right BLUE?","timeout_ms":15000}
→ {"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
← {"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 от хоста]
← {"type":"confirm_request","id":"btn2_press","prompt":"Нажмите Test_But_2","timeout_ms":10000}
← {"type":"test_result","id":"buttons","status":"pass","ms":3821,"detail":""}
← {"type":"confirm_request","id":"btn2_press","prompt":"Press Test_But_2","timeout_ms":10000}
← {"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 |
| ---------- | ---------------- | ---------------- | -------- | ------ | ------------- |
| `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ |
| `qspi` | QSPI Flash 8 MB | self | ✅ | ❌ | ❌ |
| `usd` | uSD SDIO | self+interactive | ❌ | ❌ | ✅ pre_confirm |
| `display` | Display RGB888 | interactive | ❌ | ❌ | ✅ 4×в run() |
| `buttons` | Test_But_1/2 | interactive | ❌ | ❌ | prompt only |
| `can` | CAN loopback | HIL | ❌ | ✅ | ❌ |
| `uart_ttl` | UART TTL | HIL | ❌ | ✅ | ❌ |
| `uart_iso` | UART ISO / RS_RX | HIL | ❌ | ✅ | ❌ |
| `opto` | Opto EXT_IN1/IN2 | HIL | ❌ | ✅ | ❌ |
Порядок — как в реестре `k_registry[]` (`test_runner.c`).
| № | ID | Название | Тип | Critical | M5 HIL | Confirm |
| --- | --------- | ------------------ | ---------------------------- | -------- | ------ | --------------- |
| 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ |
| 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ |
| 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm |
| 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() |
| 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only |
| 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) |
| 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) |
| 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() |
**Типы confirm:**
- **pre_confirm** — test_runner отправляет `confirm_request` до вызова `run()`,
ждёт JSON-ответ через state machine (асинхронно).
- **pre_confirm**`test_runner` отправляет `confirm_request` до вызова `run()`,
ждёт JSON-ответ через state machine (асинхронно). id = id теста.
- **в run()** — тест сам вызывает `test_runner_wait_confirm()` изнутри `run()`,
блокируется до ответа (синхронно).
- **prompt only**`protocol_send_confirm_request()` отправляется как UI-подсказка,
- **prompt only**`protocol_send_confirm_request()` как UI-подсказка,
хост не отвечает JSON, таргет ждёт физического события.
- **авто (HIL)** — confirm генерирует не оператор, а хост-оркестратор, командуя
M5StampPLC (см. `README_TESTING.md`).
---
@ -540,13 +610,13 @@ static test_result_t test_foo_run(void)
return result;
}
const test_module_t k_test_foo = {
.id = "foo", /* короткий ASCII-ключ */
const test_module_t K_TEST_FOO = {
.id = "foo", /* короткий ASCII-ключ */
.name = "Foo Peripheral",
.critical = false, /* true → run_all стопится при fail */
.requires_hil = false, /* true → нужен M5StampPLC */
.pre_confirm_prompt = NULL, /* строка → pre_confirm механизм */
.init = NULL, /* bsp_foo_init если нужен */
.requires_hil = false, /* true → нужен M5StampPLC */
.pre_confirm_prompt = NULL, /* строка → pre_confirm механизм */
.init = NULL, /* bsp_foo_init если нужен */
.run = test_foo_run,
.deinit = NULL,
};
@ -558,15 +628,19 @@ const test_module_t k_test_foo = {
```c
/* Forward declarations */
extern const test_module_t k_test_sdram;
extern const test_module_t k_test_foo; /* ← добавить */
extern const test_module_t K_TEST_SDRAM;
extern const test_module_t K_TEST_FOO; /* ← добавить */
static const test_module_t *const k_registry[] = {
&k_test_sdram,
&k_test_foo, /* ← добавить */
&K_TEST_SDRAM,
...
&K_TEST_FOO, /* ← добавить */
};
```
> При росте реестра выше `TEST_REGISTRY_MAX_SIZE` (`test_module.h`) сборка
> упадёт на `_Static_assert` в `test_runner.c` — увеличить константу.
### Шаг 3 — Добавить в CMakeLists.txt
**Файл:** `firmware/test/CMakeLists.txt`
@ -578,7 +652,6 @@ add_executable(
src/cli.c
src/protocol.c
src/test_runner.c
src/tests/test_sdram.c
src/tests/test_foo.c # ← добавить
...
)
@ -592,24 +665,18 @@ target_link_libraries(
### Шаг 4 — Обновить матрицу тестов
Добавить строку в таблицу в этом README.
Добавить строку в таблицу в этом README (и, если есть протокольный поток —
в `README_TESTING.md`).
### Шаблоны для разных типов тестов
#### Self-тест с инициализацией
```c
static void test_foo_init(void)
{
bsp_foo_init();
}
static void test_foo_init(void) { bsp_foo_init(); }
static void test_foo_deinit(void) { bsp_foo_deinit(); }
static void test_foo_deinit(void)
{
bsp_foo_deinit();
}
const test_module_t k_test_foo = {
const test_module_t K_TEST_FOO = {
.id = "foo",
.init = test_foo_init,
.run = test_foo_run,
@ -622,7 +689,6 @@ const test_module_t k_test_foo = {
```c
#include "test_runner.h" /* test_runner_wait_confirm() */
#include "protocol.h" /* protocol_send_confirm_request() */
static test_result_t test_foo_run(void)
{
@ -650,13 +716,13 @@ static test_result_t test_foo_run(void)
#### Тест с pre_confirm (вставить карту, подключить кабель)
```c
const test_module_t k_test_foo = {
const test_module_t K_TEST_FOO = {
.id = "foo",
.pre_confirm_prompt = "Подключите кабель к разъёму X и нажмите OK",
.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)` |
| `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` — единственная точка правды о версии.
Поле `"fw"` в `session_start` несёт эту строку.
Текущая версия ПО задается с помощью `project(firmware_test VERSION X.Y.Z)`
в `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

@ -1,10 +1,15 @@
# firmware_test — Руководство по тестированию
Тестовая прошивка входного контроля платы MIMXRT1052CVJ5B.
Тестовая прошивка входного контроля платы TFT индикатора.
Транспорт: USB CDC ACM (J2). Протокол: JSON-lines v2, один JSON-объект на строку.
Загружается в 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":"run","id":"sdram"}` | Запустить один тест по ID |
| `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":"list_tests"}` | Получить реестр тестов с метаданными |
| `cmd` | `{"type":"cmd","cmd":"get_uid"}` | Прочитать UID чипа (OCOTP) |
| `cmd` | `{"type":"cmd","cmd":"get_version"}` | Прочитать версию прошивки |
| `confirm` | `{"type":"confirm","id":"usd","confirmed":true}` | Ответ оператора на запрос |
### Исходящие события (target → host)
@ -30,8 +37,10 @@
| `test_begin` | `id`, `name`, `critical` | Тест запущен |
| `test_result` | `id`, `status`, `ms`, `detail` | Результат теста |
| `confirm_request` | `id`, `prompt`, `timeout_ms` | Запрос оператору |
| `progress` | `test`, `step`, `status` | Прогресс внутри теста |
| `progress` | `test`, `step`, `status` | Прогресс внутри теста (usd) |
| `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` |
| `{"ok":false,"error":"..."}` | `error` | Ошибка протокола |
@ -39,12 +48,14 @@
**Коды ошибок в `error`:**
| Код | Причина |
| -------------- | --------------------------------------------------------------- |
| `BUSY` | Предыдущий тест ещё выполняется |
| `UNKNOWN_TEST` | ID теста не найден в реестре |
| `PARSE_ERR` | Не удалось разобрать JSON (нет поля `type`, `cmd`, `id` и т.д.) |
| `UNKNOWN_CMD` | Неизвестный тип сообщения или команда |
| Код | Причина |
| --------------- | --------------------------------------------------------------- |
| `BUSY` | Предыдущий тест ещё выполняется |
| `UNKNOWN_TEST` | ID теста не найден в реестре |
| `PARSE_ERR` | Не удалось разобрать JSON (нет поля `type`, `cmd`, `id` и т.д.) |
| `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`:
```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)
@ -64,6 +75,19 @@
← {"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 — контроль оперативной памяти
@ -179,7 +203,7 @@ sequenceDiagram
| Время выполнения | < 1 с после вставки карты |
Оператор вставляет карту по запросу. Тест запускается только после подтверждения.
Отказ или таймаут 30 с`SKIP`.
Отказ или таймаут 30 с`SKIP`. confirm id для pre-confirm равен id теста (`usd`).
Пять шагов с `progress`-событиями: card detect → mount → write 4 KB → read/compare → unmount.
@ -246,7 +270,8 @@ sequenceDiagram
**pre-confirm отсутствует** — `test_begin` отправляется сразу после `run`.
Два этапа, каждый шаг требует подтверждения оператора (таймаут 15 с → FAIL):
Два этапа, каждый шаг требует подтверждения оператора (таймаут 15 с → FAIL);
тест прерывается на первом неподтверждённом шаге:
- **Этап 1 (все дисплеи):** Red → Green → Blue → White
- **Этап 2 (TFT7/8/10):** паттерн Red/Blue + горизонтальный флип — диагностика непропаянных LR/UD пинов
@ -379,8 +404,9 @@ sequenceDiagram
| Тип | Interactive (in-run confirm) |
| Время выполнения | ~4 с воспроизведение + до 15 с на confirm |
Тест воспроизводит мелодию (~4 с: нота A4 затем E5) через MQS-выход
(`MQS_RIGHT`, `GPIO_AD_B0_04`) и усилитель LM4875M.
Тест воспроизводит мелодию (~4 с: нота A4 затем E5, стерео PCM16 44100 Гц)
через MQS-выход (`MQS_RIGHT`, `GPIO_AD_B0_04`) и усилитель LM4875M.
Буфер — статический в некэшируемой секции (OCRAM NonCacheable), L == R.
Оператор подтверждает слышимость тона.
```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)
| Параметр | Значение |
@ -583,7 +468,8 @@ HIL-тесты (`requires_hil=true`) недоступны если M5StampPLC н
| 6 | `opto_rs_inactive` | RLY2 OFF | `BSP_OPTO_CH_RS == INACTIVE` |
HIL confirm полностью автоматический — TUI командует M5 и отправляет confirm
без участия оператора.
без участия оператора. После confirm тест выжидает settle (~30 мс, перекрывает
debounce 10 мс) и читает состояние через `bsp_opto_force_read()`.
```bash
→ {"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
Два файла тестируют `test_opto` и `test_can` через реальный CDC-протокол v2.

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 для точечных правок.