# service-tui: docs refactored

This commit is contained in:
Dmitry Akimov 2026-07-06 19:07:32 +03:00
parent 6d23069103
commit c6bc0e9935
11 changed files with 404 additions and 1690 deletions

View file

@ -76,7 +76,6 @@ target_link_libraries(firmware_test PRIVATE bsp_provisioning)
| `bsp_status` | PUBLIC | `bsp_status_t` в публичном API |
| `bsp_board` | PRIVATE | Транзитивно: clock, SDK headers |
---
## Особенности

View file

@ -1,11 +1,10 @@
# service-tui — TUI сервисного инженера
TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе.
Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows.
Написано на Python + [Textual](https://textual.textualize.io/). Работает на MacOS, Windows.
> Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков —
> в [DEV_ARCH.md](DEV_ARCH.md). Этот документ — только про то, как приложением
> пользоваться.
> в [DEV_ARCH.md](docs/DEV_ARCH.md)
---
@ -18,11 +17,13 @@ TUI-приложение для диагностики и прошивки пл
```bash
┌────────────────────────────────────────────────────┐
│ service_tool v0.3.0
│ service_tool vX.Y.Z
│ │
│ [LOGO_ART] │
│ │
│ Подключите плату индикатора к USB... ⠋ │
│ │
│ [ ✕ Выйти из приложения ] │
└────────────────────────────────────────────────────┘
```
@ -55,29 +56,37 @@ TUI не пытается восстановить прежнее состоян
│ │
│ ████████████░░░░░░ ← без числового % │
│ ┌────────────────────────────────────────────┐ │
│ │ ▶ Сборка HAB-образа (nxpimage)... │ │
│ │ ▶ Прошивка: TFT_BOOTLOADER_NEW │ │
│ │ ▶ Прошивка: firmware_test │ │
│ │ ... │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
```
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
во время активной операции (скрыт в простое), без числового `%` — только
полоса и построчный лог в реальном времени. Панель выбора прошивки
ограничена по высоте и скроллится сама, если разрастается (варианты
"Другое") — лог снизу гарантированно не сжимается меньше 6 строк.
**Если после «Загрузить» появилась ошибка, а плата всё ещё видна на этом же
экране** — это ожидаемо: логическая ошибка (не найден файл, не подошёл
формат) не выкидывает на экран ожидания, потому что плата физически
подключена. Прочитайте сообщение в логе, поправьте выбор и нажмите
«Загрузить» ещё раз. На экран ожидания TUI переключает только при реальном
физическом обрыве USB.
**"Другое" — для бинарников, собранных не в этом репозитории.** В
`custom_binaries/` кладётся **сырой** образ (код + таблица векторов, без
FCB/IVT/DCD — то же самое, что `build/Debug/bootloader.bin` до `nxpimage`).
TUI сама собирает из него загружаемый образ на лету:
`custom_binaries/` кладётся бинарник — сырой (код + таблица векторов, без
FCB/IVT/DCD) либо уже готовый HAB-образ, в зависимости от источника. TUI
сама достраивает недостающее на лету:
1. `nxpimage hab export` — добавляет IVT (+DCD, если включён тумблер "Использует SDRAM")
2. в Flash пишется явный FCB под выбранную память платы (не тот же
1. Собирает загружаемый HAB-образ (добавляет IVT, +DCD — если включён
тумблер "Использует SDRAM")
2. В Flash пишется явный FCB под выбранную память платы (не тот же
auto-config, что для штатных `firmware_test`/`bootloader`/`app` — для
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md`)
3. образ прошивается с `0x60001000`, как обычно
W25Q256/512 он ненадёжен, см. `DEV_ARCH.md §8.4`)
3. Образ прошивается стандартным адресом
**Нужен ли тумблер DCD — зависит от конкретного бинарника, не от того, в
каком виде он получен.** Одна и та же связка `bootloader + tft_app` не
требует DCD, а часть кастомных/легаси образов (например, старый загрузчик,
используемый на производстве) требует его независимо от формата файла. Если
не уверены, нужен ли конкретному образу DCD — уточните у того, кто его
предоставил, прежде чем прошивать.
**Выбор запоминается на весь запуск TUI** — файл, память платы и DCD не
нужно выставлять заново на каждой следующей плате: прошили одну, вынули
@ -96,8 +105,8 @@ Production/Custom этот шаг не нужен).
│ Переведите плату в нормальный режим: │
│ BOOT_MOD_1 → GND → Reset │
│ │
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
│ Автопереход через: 40с
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
└────────────────────────────────────────────────────┘
```
@ -128,6 +137,7 @@ Production/Custom этот шаг не нужен).
```
Что важно знать:
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
все"/"Снять все".
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
@ -147,7 +157,7 @@ Production/Custom этот шаг не нужен).
---
## Рабочие процессы сервисника
## Рабочие процессы сервисного инженера
### Диагностика (firmware_test уже прошит)
@ -174,11 +184,12 @@ Production/Custom этот шаг не нужен).
Для плат старых ревизий и любых образов, собранных не в этом репозитории.
```bash
1. Положить сырой бинарник (без FCB/IVT/DCD) в custom_binaries/
1. Положить бинарник (сырой или уже HAB, см. раздел выше) в custom_binaries/
(или в директорию из SERVICE_CUSTOM_BINARIES_DIR)
2. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD при необходимости
4. Загрузить — TUI сама соберёт HAB-образ и запишет правильный FCB
3. Выбрать «Другое» → выбрать файл → выбрать память платы → DCD, если
конкретно этот образ его требует (уточнить у источника файла)
4. Загрузить — TUI сама соберёт HAB-образ (если нужно) и запишет правильный FCB
5. Партия одинаковых плат: вынуть USB, вставить следующую — выбор уже
подставлен, останется нажать «Загрузить»
```
@ -196,14 +207,6 @@ Production/Custom этот шаг не нужен).
## Конфигурация (`.env`)
```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 (наше устройство)
SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad
@ -212,7 +215,7 @@ SERVICE_CDC_PID=00ad
SERVICE_M5_VID=303a
SERVICE_M5_PID=4001
# Директория с сырыми кастомными бинарниками для FlashScreen → "Другое".
# Директория с кастомными бинарниками для FlashScreen → "Другое".
# По умолчанию — custom_binaries/ рядом с исполняемым файлом (или рядом
# с main.py в dev-режиме); создаётся автоматически при старте.
# SERVICE_CUSTOM_BINARIES_DIR=/path/to/custom_binaries
@ -221,10 +224,20 @@ SERVICE_M5_PID=4001
# Release временно нестабилен — по умолчанию Debug.
FIRMWARE_BUILD_TYPE=Debug
# Уровень логирования. По умолчанию INFO (плюс WARNING принудительно для
# шумных модулей spsdk/libusbsio). DEBUG — полный лог, включая построчные
# HID-дампы каждой команды spsdk (для диагностики проблем прошивки).
# SERVICE_LOG_LEVEL=DEBUG
# Опционально: путь к директории лога TUI
# SERVICE_LOG_DIR=/tmp
```
> `.env` не загружается в упакованном (frozen) приложении — standalone-бинарь
> работает на встроенных значениях по умолчанию. Переменные окружения (не
> `.env`-файл) по-прежнему действуют и во frozen-режиме, если их выставить
> перед запуском.
---
## Запуск
@ -232,45 +245,56 @@ FIRMWARE_BUILD_TYPE=Debug
### Из монорепозитория (разработчик)
```bash
just host::service-setup # установить зависимости tools/production/
just host::service-setup # установить/обновить зависимости tools/production/
just host::service-tui # запустить TUI
```
### Standalone-бинарь (сервисник)
Распаковать `service-tui-vX.Y.Z-<os>.zip` в любую директорию и запустить
`service_tui` (`service_tui.exe` на Windows). Файл самодостаточен — не
требует установленного Python, `uv`, драйверов (Zadig/WinUSB) или сетевого
доступа.
### Сборка релизного бандла (разработчик)
```bash
just host::service-build
# → tools/production/dist/service_tui
just build::hab-all-release # или hab-all-debug — собрать HAB-образы заранее
just host::package-tui # → tools/production/dist/service-tui-vX.Y.Z-<os>/
```
> Standalone-бинарь не включает `tools/host/` — для прошивки рядом нужен
> инициализированный `tools/host/` (`just host::setup-tools`), либо
> абсолютный путь в `_FLASH_USB_SCRIPT` (`flasher.py`).
Устройство бандла (`_internal/`, `firmware/`, `custom_binaries/`) и детали
сборки (`service_tui.spec`) — в [DEV_ARCH.md §14](DEV_ARCH.md#14-упаковка-pyinstaller-фаза-5).
> Если на Windows `package-tui` падает с `Permission denied` на шаге
> переименования — закройте запущенный `service_tui.exe` от предыдущей
> сборки и повторите (см. `DEV_ARCH.md §14.4`).
---
## Зависимости
| Пакет | Версия | Назначение |
| --------------- | ------ | ----------------------------------------------------- |
| --------------- | ------ | -------------------------------------------------------------------------------------- |
| `textual` | ≥ 0.80 | TUI фреймворк |
| `pyserial` | ≥ 3.5 | USB CDC ACM + M5 Serial |
| `pyusb` | ≥ 1.0 | детект BootROM SDP (не виден через pyserial на macOS) |
| `pyserial` | ≥ 3.5 | USB CDC ACM (firmware_test) + Serial (M5StampPLC) |
| `spsdk` | 3.7.0 | прошивка in-process: SDP, McuBoot, HabImage |
| `python-dotenv` | ≥ 1.0 | загрузка `.env` |
| `pyinstaller` | ≥ 6.0 | сборка standalone-бинаря |
**Runtime-зависимость (не в `pyproject.toml`):** `flasher.py` вызывает
`tools/host/flash_usb.py` через `uv run``tools/host/` должен быть
инициализирован (`just host::setup-tools`).
| `pyinstaller` | ≥ 6.0 | сборка standalone-бандла |
| `pyusb` | ≥ 1.0 | не используется текущей детект-логикой (см. `DEV_ARCH.md §2`), оставлен в зависимостях |
---
## Логирование
```bash
tools/production/service_tui.log ← по умолчанию
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env
tools/production/service_tui.log ← по умолчанию (рядом с main.py в dev,
рядом с exe во frozen)
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env / окружении
```
Уровень: `DEBUG` для модулей приложения, `WARNING` для самого textual. TUI не
пишет в stdout — Textual захватывает терминал.
Уровень по умолчанию: `INFO` для модулей приложения (`WARNING` для `textual`
и принудительно для шумных модулей `spsdk`/`libusbsio`, которые на `DEBUG`
печатают построчные HID-дампы каждой команды). Полный `DEBUG` — через
`SERVICE_LOG_LEVEL=DEBUG`. TUI не пишет в stdout — Textual захватывает
терминал.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -6,34 +6,42 @@
> взаимодействия с firmware/M5, экранную архитектуру Textual, известные
> особенности фреймворка.
> Пользовательская документация (экраны, запуск, конфигурация,
> рабочие процессы сервисника) — в [README.md](README.md).
> рабочие процессы сервисника) — в [README.md](../README.md).
---
## 1. Структура проекта
## 1. Структура проекта
```bash
tools/production/
├── main.py ← точка входа (10 строк)
├── main.py ← точка входа
├── pyproject.toml ← зависимости uv
├── dist/ ← дистрибутивы программы (PyInstaller)
├── uv.lock
├── custom_binaries/ ← runtime, gitignored, создаётся автоматически
│ сырые (без FCB/IVT/DCD) бинарники для FlashScreen → «Другое»
└── app/
├── service_tui.spec ← PyInstaller spec
├── custom_binaries/ ← runtime, создаётся автоматически;
│ сырые/готовые бинарники для FlashScreen → «Другое»
└── app/ ← implicit namespace package
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
├── app.tcss ← единый файл стилей для всех экранов
├── models.py ← все типы данных (dataclass/Enum)
├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── flasher.py ← subprocess-обёртка над tools/host/flash_usb.py
├── firmware_client.py ← async USB CDC клиент firmware_test
├── m5_client.py ← async M5StampPLC клиент
├── flash_backend.py ← spsdk 3.7.0 in-process: SDP, McuBoot, HabImage
├── flasher.py ← async-обёртка над flash_backend для Textual workers
├── usb_ports.py ← резолвер serial-портов по VID:PID
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
├── widgets/
│ ├── __init__.py
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
└── screens/
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер, кнопка «Выйти»
├── flash.py ← FlashScreen — прошивка / chip erase
├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
@ -55,7 +63,7 @@ graph LR
subgraph app["app/"]
FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
M5["m5_client.py\nSerial JSON-lines, UTF-8"]
FL["flasher.py\nsubprocess + pyusb detect"]
FL["flasher.py + flash_backend.py\nspsdk in-process: SDP/McuBoot/HabImage"]
OR["orchestrator.py\nconfirm/progress/timeout router"]
end
TUI --> FC & M5 & FL & OR
@ -70,22 +78,16 @@ graph LR
M5HW["M5StampPLC\nRLY14 + CAN"]
end
subgraph Host["tools/host/"]
FU["flash_usb.py\nsdphost + blhost"]
end
FC <-->|"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL -->|"subprocess uv run"| FU
FU -->|"sdphost + blhost\nVID:PID 1FC9:0130"| ROM
M5 <-->|"JSON-lines\nSerial"| M5HW
FC |"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL |"spsdk (libusbsio HID)\nVID:PID 1FC9:0130 / 15A2:0073"| ROM
M5 |"JSON-lines\nSerial"| M5HW
M5HW -->|"RLY14"| Board
```
> **Детект USB:** `Flasher.detect_sdp()`/`detect_cdc()` используют `pyusb` как
> основной метод (BootROM SDP не создаёт serial-порт на macOS и невидим через
> `pyserial.list_ports`), с fallback на `serial.tools.list_ports` для CDC.
> M5StampPLC детектируется отдельно в `m5_client.py` тем же способом
> (`pyusb`, VID/PID из `.env` — см. раздел 5).
> **Детект USB:** `flash_backend.detect_sdp()`/`detect_cdc()` используют spsdk
> напрямую (`SdpUSBInterface.scan()` / `MbootUSBInterface.scan()`, HID-транспорт
> через `libusbsio`). CDC firmware_test и M5StampPLC резолвятся через
> `pyserial` (`usb_ports.py::resolve_serial_port()`, `m5_client.py`).
---
@ -103,7 +105,7 @@ stateDiagram-v2
WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC
FLASHING --> POST_FLASH : firmware_test прошит успешно
FLASHING --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое
FLASHING --> WAITING : Production/Custom прошит,\nили потеря USB (в простое ИЛИ во время операции)
POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
@ -115,6 +117,13 @@ stateDiagram-v2
`ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на
сообщения `DeviceDetected`/`FlashDone`/`DiagDone`.
> **Важная деталь, не показанная на диаграмме**:
> `FLASHING --> WAITING` по стрелке «ошибка» срабатывает **только** при
> физическом обрыве USB (`FlashResult.connection_lost=True`). Логическая
> ошибка (файл не найден, битый custom-бинарь) — плата на месте, экран
> остаётся на `FLASHING` (нет перехода состояния вообще, поэтому на
> диаграмме это не отдельная стрелка). См. §6.
---
## 4. Обработка confirm_request
@ -154,9 +163,7 @@ flowchart TD
**Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно
одним событием `SUMMARY` — настоящим от firmware или синтетическим
(`aborted: true`), если чтение порта оборвалось по таймауту. Без этой гарантии
зависший тест блокировал бы кнопки "Выйти" и повторного запуска навсегда
(исторический баг, см. `CHANGELOG.md`).
(`aborted: true`), если чтение порта оборвалось по таймауту.
---
@ -184,11 +191,6 @@ flowchart TD
PID — при детекте ориентироваться на `just host::m5-scan`, а не на
документацию, если она когда-либо разойдётся с кодом.
**Важно на будущее:** документация (`HIL_BENCH.md`/`HIL_HOW_TO.md`) местами не
успевает за изменениями `agent.py`. При любых будущих изменениях протокола
агента (новые команды, смена формата ответа) — сверяться напрямую через
`grep` по `tools/hil/m5/agent.py`, а не полагаться только на документацию.
---
## 6. Мониторинг соединения и разрыв сессии
@ -197,22 +199,41 @@ flowchart TD
`FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине.
- **На `FlashScreen`** — проверка приостановлена во время активной
прошивки/erase (обрыв обнаружит сам `flash_usb.py` subprocess).
прошивки/erase (`self._flashing == True`).
- **На `DiagScreen`** — проверка приостановлена во время прогона тестов
(обрыв надёжнее детектирует таймаут чтения порта внутри `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 не пытается определить, вернулась ли та же плата, просто стартует
диагностику с нуля.
заново с нуля.
---
@ -251,15 +272,14 @@ AppFrame {
### 8.1 Проблема
Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются
`nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 —
для них auto-config Flashloader (`configure-memory 0xC0000007` →
`0xF000000F`, см. `HOW_TO_FLASH.md`) достаточен. Для сторонних/легаси
бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader
не документирован как надёжный для 4-байтной адресации, а сами бинарники
приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат,
что `inputImageFile` в `hab_*.yaml` до сборки). Решение — собирать HAB
на лету и писать FCB явно, а не полагаться на auto-config.
Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются заранее
(`just build::hab-*`) и всегда идут на плату с W25Q128 — для них auto-config
Flashloader достаточен. Для сторонних/легаси бинарников (старые платы,
W25Q256/512) это не так: auto-config Flashloader не документирован как
надёжный для 4-байтной адресации, а сами бинарники приходят «сырыми» (код +
таблица векторов, без FCB/IVT/DCD) либо уже готовым HAB-образом — зависит от
источника. Решение — собирать HAB на лету (если нужно) и писать FCB явно, а
не полагаться на auto-config.
### 8.2 Модели (`models.py`)
@ -286,53 +306,44 @@ class FlashPreset:
одинаковых плат подряд — вставил, TUI уже подставила прошлый выбор файла/
памяти/DCD, нажал «Загрузить», вынул, вставил следующую.
Рассматривался отдельный режим «массовое программирование» (авто-прошивка
по факту детекта SDP, без нажатия кнопки на каждую плату) — отклонён:
в SDP/Flashloader-режиме нет способа прочитать UID платы, авто-старт без
подтверждения оператора убирает последний шанс заметить, что в руках не та
плата. Оставлена только «липкая» память выбора (этот раздел).
**Нужен ли DCD — implementation-defined, зависит от конкретного бинарника,
не от его формата (сырой/готовый HAB).** Правило «сырой → включить DCD,
готовый HAB → выключить» **неверно как общее правило**: например, в связке
`bootloader + tft_app` сам `bootloader` не требует DCD, а часть кастомных
бинарников (в т.ч. старый загрузчик, используемый на производстве) требует
DCD независимо от того, в каком виде получен файл. Оператор должен знать
по конкретному образу, инициализирует ли он SDRAM самостоятельно — TUI не
может определить это автоматически по содержимому файла.
### 8.3 Конвейер сборки (`flasher.py`)
```
```bash
Flasher.flash(target=CUSTOM, bin_path, use_dcd, fcb_variant, progress_cb)
└── _run_flash_custom()
├── _build_custom_hab(raw_bin, use_dcd, progress_cb)
│ ├── генерирует temp .yaml в tools/host/hab/ (по образцу hab_bootloader_*.yaml:
│ │ startAddress=0x60000000, ivtOffset=0x1000, initialLoadSize=0x2000,
│ │ family=mimxrt1050, + DCDFilePath: ../dcd/dcd.bin если use_dcd)
│ ├── uv run nxpimage hab export --force -c <yaml> -o <out>,
│ │ cwd=tools/host/hab/ (обязательно — relative DCDFilePath
│ │ резолвится от этой директории, как в build.just)
│ └── стриминг stdout nxpimage в progress_cb (не только logger.debug —
│ иначе во время сборки лог FlashScreen выглядит «зависшим»)
└── flash_usb.py --bin-path <hab_bin> --fcb-path tools/host/dcd/{fcb_variant}_fdcb.bin
(временный .yaml и собранный HAB-образ удаляются после прошивки)
└── _flash_custom()
├── _build_custom_hab(raw_bin, use_dcd, progress_cb)
│ └── flash_backend.build_custom_hab() — in-process spsdk API:
│ Config (family=mimxrt1050, startAddress=0x60000000,
│ ivtOffset=0x1000, initialLoadSize=0x2000,
│ + DCDFilePath, если use_dcd) → HabImage.export()
└── _run_flash_op(flash_backend.flash, hab_bin, fcb_path=...)
временный HAB-образ удаляется после прошивки
(finally: shutil.rmtree(hab_bin.parent))
```
`dcd/dcd.bin` — один и тот же файл независимо от проекта (SEMC/SDRAM-init не
зависит от того, что именно исполняется), поэтому просто константный путь,
без вариантов.
`dcd/dcd.bin` (`tools/host/dcd/dcd.bin`) — один и тот же файл независимо от
проекта (SEMC/SDRAM-init не зависит от того, что именно исполняется), простой
константный путь, без вариантов. Резолвится через `flash_backend._host_dcd_dir()`
— двухрежимный (dev/frozen), см. §14.
### 8.4 `flash_usb.py` — явная запись FCB вместо auto-config
### 8.4 Явная запись FCB вместо auto-config
```python
def write_fcb_explicit(fcb_path: Path) -> None:
"""write-memory 0x60000000 <fcb_path> — буквальная запись 512-байтного
FCB-блоба (tag 'FCFB'), а не magic option word 0xF000000F.
Обязателен для кастомных бинарей — auto-config Flashloader проверен
только для W25Q128."""
```
`flash_backend.py::write_fcb_explicit()``write_memory(0x60000000, fcb_bin)`,
буквальная запись 512-байтного FCB-блоба (tag `FCFB`), а не magic option word
`0xF000000F`. Обязателен для кастомных бинарей — auto-config Flashloader
проверен только для W25Q128 (см. §Известные открытые вопросы).
Активируется флагом `--fcb-path` (только вместе с `--bin-path`). Штатный
`--firmware`-путь (три сборки из `BUILD_DIR`) не тронут: без `--fcb-path`
поведение идентично тому, что было до этой доработки.
Заодно увеличен таймаут `blhost` для `flash-erase-all` (chip erase) —
`-t 200000` вместо дефолтного: W25Q512 стирается заметно дольше W25Q128,
дефолтного таймаута `blhost` не хватало. `flash-erase-region` (стирание
пары секторов под FCB+HAB при обычной прошивке) не трогали — там масштаб
на порядки меньше, дефолта достаточно независимо от чипа.
Штатный путь (`firmware_test`/`bootloader`/`app` из `build/<Type>/`) не
затрагивается — использует auto-config, как и раньше.
### 8.5 UI (`flash.py`)
@ -348,6 +359,13 @@ def write_fcb_explicit(fcb_path: Path) -> None:
дополнительно защищён `min-height: 6` — лог гарантированно виден даже в
худшем случае.
**Троттлинг лога:** прогресс-бар обновляется на каждом
событии `FlashProgress`, но `#flash-log` для фазы `write` пишет только при
пересечении 10%-границы — без этого запись HAB-образа даёт ~135 строк в лог
на одну прошивку. Первая строка фазы (`"Запись <имя> (<размер> байт)"`) всегда
проходит; остальные фазы (`configure`/`erase`/`fcb`/`reset`/`error`) логируются
без троттлинга — их и так немного.
---
## 9. Архитектура экранов
@ -372,7 +390,7 @@ graph TB
subgraph Clients["Клиенты"]
FC["FirmwareClient"]
M5["M5Client"]
FL["Flasher"]
FL["Flasher\n(async) + flash_backend\n(spsdk in-process)"]
end
WS -->|"DeviceDetected(FLASHING)"| FS
@ -411,7 +429,7 @@ sequenceDiagram
OP->>TUI: запустить service_tui
TUI->>WS: push_screen()
WS->>WS: pyusb poll каждые 1.5 с
WS->>WS: USB poll каждые 1.5 с
OP->>FW: подключить плату USB
WS->>TUI: DeviceDetected(DIAGNOSING)
@ -471,7 +489,7 @@ sequenceDiagram
---
## 11. Версионирование firmware
## 11. Версионирование firmware и TUI
`firmware_test` версионируется через CMake
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
@ -484,7 +502,10 @@ sequenceDiagram
отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из
`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).
---
@ -523,7 +544,9 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
`mount_all()`.
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня
проекта — постоянно расходится при рефакторинге структуры. Решение: один
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`. Во frozen
дополнительно требует, чтобы `app.tcss` физически лежал в бандле по тому же
относительному пути (см. §14).
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает
ширину по умолчанию равную длине заголовка — длинный контент обрезается
независимо от `height` строки. Нужно использовать `add_column(label,
@ -547,14 +570,103 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
---
## 14. Упаковка
### 14.1 Структура бандла
```bash
service-tui-vX.Y.Z-<os>/
├── service_tui[.exe]
├── _internal/
│ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data
│ └── ... ← рантайм PyInstaller, libusbsio (из Analysis)
├── firmware/
│ └── <Type>/firmware_test_hab.bin ← копируется post-build
└── custom_binaries/ ← пустая, для оператора
```
Два разных механизма наполнения — не взаимозаменяемы:
- **`_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 |
| -------------------------------------------------------------------- | -------------------------------- | ------------------------------------------- |
| `firmware_hab_path()` | `BUILD_DIR`/`build/<Type>/` | `sys.executable.parent / "firmware"` |
| `_host_dcd_dir()` | `tools/host/dcd/` | `sys._MEIPASS / "data"` |
| `flashloader_bin_path()` / `real_dcd_bin_path()` / `fcb_blob_path()` | производные от `_host_dcd_dir()` | |
| `_resolve_custom_binaries_dir()` (`flasher.py`) | рядом с `main.py` | `sys.executable.parent / "custom_binaries"` |
`main.py::_setup_logging()` и `.env`-загрузка тоже различают режимы:
лог-файл во frozen пишется рядом с exe (не внутрь `_internal/`); `.env` во
frozen не подгружается вообще (frozen-сборка работает на fallback-константах
в коде, не полагаясь на файл, которого в бандле нет).
### 14.3 `service_tui.spec` — сборка (важные детали)
- **onedir, не onefile** — onefile ощутимо медленнее стартует (распаковка во
временную директорию при каждом запуске).
- **`collect_data_files("spsdk")`** — обязателен, не перестраховка: ~380
файлов (`data/devices/*/database.yaml` и т.п.), которые реально резолвит
`HabImage`/`Config` для `family=mimxrt1050`.
- **`collect_dynamic_libs("libusbsio")`** — заберёт бинарники **всех**
поддерживаемых платформ (`bin/osx_arm64/`, `bin/x64/`, `bin/linux_*` и
т.д. — `rglob` без фильтра по текущей ОС). Не баг: сама `libusbsio.py`
резолвит нужный файл в рантайме по `platform.system()`/`platform.machine()`,
лишние платформы просто раздувают бандл. При необходимости можно
отфильтровать под текущую ОС отдельно.
- **`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 шаге может упасть с
`Permission denied`**, если целевая директория из предыдущей сборки ещё
содержит заблокированный файл (например, `service_tui.exe` от прошлого
запуска, не закрытый перед повторной упаковкой, либо антивирус временно
удерживает хендл на свежесозданном `.exe`). Симптом: сообщение об ошибке
показывает путь **вложенным** (`dist/service-tui-vX.Y.Z-windows/service_tui`)
— это Unix-семантика `mv` в существующую директорию, сигнал, что `rm -rf`
не до конца очистил цель. Лечится закрытием запущенного exe перед повторной
упаковкой.
- **`just` + bash-shebang рецепты на Windows** — на некоторых машинах поиск
`bash` через PATH может резолвиться в `C:\Windows\System32\bash.exe`
(WSL-заглушка) вместо Git Bash, если WSL сконфигурирован некорректно —
проявляется как `WSL (...) ERROR: execve(/bin/bash) failed`. Специфично
для конкретной машины/PATH, не для рецепта — решается на уровне окружения
(порядок PATH, состояние WSL), не в `Justfile`.
---
## Известные открытые вопросы
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
форсирует Debug через `FIRMWARE_BUILD_TYPE`.
- **Release-сборка firmware нестабильна** : работает только с оптимизацией уровня O1
- **`tools/shared/m5_agent.py`** — сознательно не делался: pytest
HIL-окружение и TUI используют независимые M5-клиенты, признано правильным
архитектурным решением, а не техдолгом.
архитектурным решением, а не техдолгом. (Устаревшая `just host::service-build`
ссылается на несуществующий `tools/shared/` через `--add-data` — рецепт,
скорее всего, нерабочий, кандидат на удаление в пользу `package-tui`.)
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
«копирование UID с экрана» — отложены, не начаты.
- **Массовое программирование** — решено НЕ делать авто-прошивку по факту
@ -564,7 +676,6 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
идентифицировать по UID.
- **Auto-config Flashloader для W25Q256/512 не проверялся напрямую** — решили
не полагаться на него вообще, для кастомных бинарей FCB всегда пишется
явно (`--fcb-path`, см. §8.4). Остаётся не до конца понятым, работает ли
явно (§8.4). Остаётся не до конца понятым, работает ли
`configure-memory 0xF000000F` для этих чипов корректно в принципе — вопрос
снят с повестки архитектурным решением, а не исследован до конца.

View file

@ -0,0 +1,115 @@
# service-tui — Единый мастер-план разработки и релиза (Master Plan v1.0)
> **Статус документа:** Консолидированный рабочий документ на основе планов миграции на монолит (V4), USB-кроссплатформенности, верификации прошивки и дорожной карты выпуска версии v1.0.
---
## 1. Контекст проекта и целевая архитектура
**Цель прошивки (`firmware_test`):** Полная диагностика платы **MIMXRT1052CVJ5B** на сервисе и производстве (обработка возвратов по рекламации). Прошивка запускается напрямую через BootROM (USB SDP) без предварительной записи загрузчика в Flash.
**Архитектура стенда:**
* **Основной канал:** Хост подключается к плате через один кабель USB CDC ACM (интерфейс `firmware_test`). UART/LPUART в сервисе сознательно не используются.
* **HIL-стенд (опционально):** Автоматические HIL-тесты управляются через модуль **M5StampPLC** (реле RLY14 + CAN-трансивер).
* **Оркестрация:** TUI-приложение на Textual одновременно координирует работу `firmware_test` (через USB CDC) и M5 (через Serial JSON-lines).
---
## 2. Закрытые архитектурные решения (Не пересматривать)
### Транспорт и парсинг протокола
* **Единый канал:** USB CDC ACM — единственный интерфейс рантайма. Строковый парсинг без использования тяжелого cJSON (через поиск `strstr` по ключевым полям `"type"` / `"cmd"`).
* **Разделение тестов:** Тесты делятся на автономные (`requires_hil=false`) и стендовые (`requires_hil=true`). Если M5StampPLC не обнаружен на шине, TUI автоматически делает HIL-тесты недоступными для выбора (окрашивает в серый цвет) и исключает их из группового запуска.
* **Динамический реестр (`list_tests`):** Плата сама отдает список поддерживаемых тестов с метаданными. TUI строит интерфейс динамически и не хардкодит список тестов внутри себя. Порядок выполнения при `run_selected` всегда определяется реестром таргета, а не порядком ID в запросе хоста.
### Аппаратная интеграция и особенности NXP BSP
* **SDRAM и DCD:** Контроллер SEMC инициализируется блоком DCD до передачи управления в `main()`. Функция `bsp_sdram_init()` выполняет исключительно верификацию стабильности памяти.
* **QSPI Flash в ITCM:** Функции работы с QSPI выполняются из быстрой памяти ITCM с использованием макросов `AT_QUICKACCESS_SECTION_CODE` и инициализации через `__STARTUP_INITIALIZE_RAMFUNCTION`.
* **W25Q256/512:** Используются выделенные 4-байтные опкоды чтения/записи/стирания без перевода чипа в глобальный 4-байтный режим (команда `0xB7`).
* **MQS Аудио:** Периферия требует стерео PCM16 буфера (SAI3 + eDMA канал 0). На плате выведен только правый канал (`MQS_RIGHT`), поэтому буфер формируется как стерео с идентичными данными L и R.
* **Порядок включения MQS:** Сначала запускается усилитель LM4875M (`bsp_mqs_amp_init()`), далее выдерживается пауза 300 мс для заряда конденсаторов C103/C105, и только потом инициализируется ядро MQS. Нарушение порядка приводит к громкому щелчку или отсутствию звука. Использование `bsp_mqs_play()` реализовано асинхронно с поллингом USB CDC во избежание голодания интерфейса.
* **PWMChannelEnable (SDK ≥ 2.13):** Флаг `pwmchannelenable` в структуре `pwm_signal_param_t` обязательно выставляется в `true`, иначе функция `PWM_SetupPwm()` не откроет выход `OUTEN`, что маскируется отладчиком и воспроизводится только при «холодном» сбросе платы.
* **ERRATA 50235 (FlexCAN + USB):** Макрос `FLEXCAN_Init()` вызывает `assert` на тактирование LPUART1 (`CCM_CCGR5_CG12`). Если после инициализации USB CDC этот гейт закрыт — плата уходит в `HardFault`. Решение: принудительный вызов `CLOCK_EnableClock(kCLOCK_Lpuart1)` перед инициализацией CAN.
* **Синхронное чтение оптовходов:** Внедрена функция `bsp_opto_force_read()` для прямого чтения состояния пинов без дебаунса, что устраняет race condition, возникающий из-за дребезга контактов реле на HIL-стенде.
### Монолитная архитектура TUI (V4) и кроссплатформенность
* **Отказ от Nuitka и venv:** Упаковка приложения выполняется исключительно штатными средствами PyInstaller в один самодостаточный каталог (`onedir`), без развертывания виртуального окружения Python на целевой машине инженера.
* **In-process spsdk:** Вся работа с BootROM SDP и McuBoot переведена на прямое использование Python API пакета `spsdk==3.7.0`. Скрипт `flash_usb.py` полностью исключен из production-цепочки и оставлен разработчикам как инструмент автоматизации в `Justfile`.
* **Нативный USB-детект без Zadig:** Устройства в режимах SDP (`1FC9:0130`) и Flashloader (`15A2:0073`) определяются как HID-устройства методами `SdpUSBInterface.scan()` и `MbootUSBInterface.scan()`. Обмен идет через системную библиотеку `libusbsio`, что полностью устраняет необходимость использования утилиты **Zadig** (WinUSB) на ОС Windows.
* **Резолв Serial-портов:** Осуществляется рантайм-поиск по VID:PID через `serial.tools.list_ports`. Явные пути к портам в `.env` используются только в качестве оверрайда (escape hatch). Платформа macOS использует неблокирующие callout-устройства `/dev/cu.usbmodem*`.
* **«Липкий» выбор (`FlashPreset`):** Выбор оператора (файл прошивки, тип памяти платы, тумблер DCD) кэшируется в контексте процесса. При прошивке партии одинаковых плат настройки подставляются автоматически, оператору достаточно нажать «Загрузить».
* **Каркас AppFrame:** Все экраны оборачиваются в фиксированный CSS-контейнер `AppFrame` (макс. размер `112x35`). Это гарантирует визуальную консистентность интерфейса и устраняет критический краш Textual 8.x при mouse drag.
---
## 3. Итоговая матрица тестов прошивки
| ID | Название теста | Critical | Требует HIL | Тип выполнения | Драйвер BSP |
| :-------- | :----------------- | :------: | :---------: | :--------------------------------- | :--------------- |
| `sdram` | SDRAM 32 MB | ✅ | ❌ | Автономный (self) | `bsp_sdram` |
| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | Автономный (self) | `bsp_qspi_flash` |
| `usd` | microSD (SDIO) | ❌ | ❌ | Интерактивный | `bsp_sd` + FatFS |
| `display` | TFT Display RGB888 | ❌ | ❌ | Интерактивный (4 цвета, 2 ротации) | `bsp_display` |
| `buttons` | Test Buttons 1/2 | ❌ | ❌ | Интерактивный (физический клик) | `bsp_button` |
| `mqs` | MQS Audio Out | ❌ | ❌ | Интерактивный (тон ~4 сек) | `bsp_mqs` |
| `can` | CAN loopback | ❌ | ✅ | Стендовый HIL (M5 CAN RX/TX) | `bsp_can` |
| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | Стендовый HIL (M5 реле 2, 3, 4) | `bsp_opto` |
*Примечание: Тесты `uart_ttl` и `uart_iso` удалены из реестра как избыточные для сервисного применения.*
---
## 4. Дорожная карта до релиза v1.0 (Текущие фазы)
### Фаза 4a — Добор типизации обрыва USB
* **Цель:** Перехват всех физических отключений кабеля во время операций и вывод унифицированного сообщения *«Соединение с платой потеряно»* вместо необработанных исключений.
* **Реализация:**
1. Расширить блоки `try-except` в `flash_backend.py` (`load_flashloader`, `flash`, `erase_chip`), обрабатывая кортеж исключений `(SPSDKConnectionError, SPSDKTimeoutError)`.
2. Внедрить «Вариант B» для долгих операций (например, `flash_erase_all`), которые при таймауте возвращают `False` без генерации исключения: в случае `ok == False` вызывать мгновенный `detect_sdp()`. Если устройство исчезло с шины — поднимать `ConnectionLostError(connection_lost=True)`. Любой сбой самой проверки трактовать как обрыв связи.
* **Критерий успеха (Гейт):** Выдергивание кабеля во время стирания или записи вызывает корректную реакцию экрана `FlashScreen`, кнопки разблокируются, лог информирует об обрыве, а повторное подключение позволяет продолжить работу без перезапуска TUI.
### Фаза 4b — Оптимизация логирования и троттлинг
* **Цель:** Исключение избыточных HID-дампов из основного лога и разгрузка текстового виджета Textual.
* **Реализация:**
1. В `main.py` установить глобальный уровень логов `INFO`. Сторонние логгеры (`spsdk`, `libusbsio`, подмодули протоколов bulk) принудительно перевести в режим `WARNING`. Полный дамп активировать только при передаче переменной окружения `SERVICE_LOG_LEVEL=DEBUG`.
2. Внедрить шаг троттлинга в `flash.py::_on_progress`. Значения в графический прогресс-бар отправлять без задержек (для плавности), а текстовые записи фазы `write` отправлять в виджет `#flash-log` только при пересечении шага в **10%** (0%, 10%, 20%... 100%).
* **Критерий успеха (Гейт):** Лог одной сессии прошивки сокращается со ~135 строк до ~10. Отсутствует визуальное замедление интерфейса.
### Фаза 5 — Упаковка через PyInstaller и полировка UI
* **Цель:** Создание бинарного дистрибутива под целевые ОС (Windows, macOS).
* **Реализация:**
1. Написать конфигурационный файл `service_tui.spec`. Использовать директиву `collect_dynamic_libs("libusbsio")` для копирования нативных библиотек HID-транспорта под текущую ОС. Библиотеки `pyusb` и `libusb-1.0` исключить из сборки.
2. Настроить секцию `datas` для переноса файлов `tools/host/dcd/` (`dcd.bin`, `*_fdcb.bin`, `ivt_flashloader.bin`) во внутреннюю папку бандла `data/`. Двухрежимный резолвер путей в коде должен прозрачно переключаться на `sys._MEIPASS` при `getattr(sys, 'frozen', False)`.
3. Скопировать стабильный отладочный образ `firmware_test_hab.bin` (Debug) в директорию `firmware/` на одном уровне с исполняемым файлом, обеспечивая возможность его замены инженерами без пересборки бандла.
4. Добавить кнопку «✕ Выйти из приложения» на стартовый экран `WaitingScreen` с привязкой к методу `self.app.exit()`.
* **Критерий успеха (Гейт):** На чистой машине (без установленного Python, uv и привязанных через Zadig драйверов) дистрибутив запускается, успешно определяет плату в SDP-режиме, загружает Flashloader, выполняет Chip Erase и зашивает диагностическую прошивку.
### Фаза 6 — Документация, CHANGELOG и выпуск релиза
* **Цель:** Финализация кодовой базы и документации.
* **Реализация:**
1. Выполнить тотальную зачистку (`grep-cleanup`) исходного кода. Удалить отладочные комментарии, неактуальные упоминания вызовов через `subprocess` и `uv run`. Актуализировать docstrings модулей `flash_backend.py` и `flasher.py`.
2. Сформировать финальный `CHANGELOG.md`, зафиксировав переход на монолит V4, нативную обработку ошибок USB-шины и отказ от Zadig.
3. Исключить инструкции по настройке Zadig из руководств `README.md` и `HOW_TO_FLASH.md`. Добавить описание ограничений первой версии (строго одна плата на стенде одновременно).
4. Создать релизный тег в Git, соответствующий текущему значению версии в `pyproject.toml`.
---
## 5. Бэклог и пост-релизные задачи (Версии v1.1+)
Задачи, согласованные к реализации, но вынесенные за рамки стабильного релиза v1.0:
1. **Этап 7 — Provisioning платы:**
* Считывание уникального аппаратного идентификатора кристалла `OCOTP_UNIQUE_ID` средствами SDK-модуля `fsl_ocotp`.
* Передача события формата `{"type":"provision_ready","chip_uid":"..."}` на хост по завершении матрицы тестов.
* Ожидание подтверждения `provision_ack` от TUI и последующая фиксация статуса успешного прохождения в первом секторе Flash-памяти за пределами исполняемой зоны XIP (проработка логики защиты от повторной перезаписи).
2. **Экспорт результатов (POST-1):** Создание обработчика для автоматической выгрузки результатов диагностики в структурированный JSON-файл с жесткой привязкой к считанному UID микроконтроллера.
3. **UID буфер обмена:** Реализация механизма копирования или выделения UID контроллера напрямую с экрана терминала `DiagScreen`.
4. **Циклический прогон тестов:** Добавление тумблера «Циклический режим» для непрерывного фонового тестирования неинтерактивных узлов платы (SDRAM, QSPI Flash, CAN, Opto) с целью выявления плавающих аппаратных дефектов и температурной нестабильности элементов.

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,452 +0,0 @@
# service-tui — миграция на монолит (V4), USB-кроссплатформенность и релиз
> Единый рабочий документ. Объединяет и заменяет `MONOLITH_PLAN.md`
> и `USB_CROSSPLATFORM.md`; заменяет шаг 3 («Упаковка», вариант B
> с venv) в `RELEASE_PLAN.md`. Шаги 12 плана релиза (merge, CHANGELOG)
> выполнены и не затрагиваются; шаги 46 переезжают в фазы 56.
Ветка: `feature-tui-monolith` от `dev`. Каждая фаза = коммит(ы) с
зелёным гейтом; откат любой фазы не ломает предыдущие.
---
## 1. Принятые решения (зафиксировано)
### Р1. Nuitka снят с повестки; упаковка — PyInstaller
Защита исходников — не требование. PyInstaller — официально
поддержанный NXP путь развёртывания spsdk. Один exe, без
`tools_host/.venv`.
### Р2. `tools/host/flash_usb.py` НЕ трогаем
Остаётся dev-CLI для `just host::flash*`, `incoming`, `production`.
TUI получает собственный нативный spsdk-backend. Прецедент закрыт
ранее: `tools/shared/m5_agent.py` сознательно не делался — независимые
клиенты признаны правильным решением, не техдолгом. Бонусы:
мгновенный откат на любой фазе и независимый эталон поведения для
hardware-гейтов.
### Р3. Соответствие CLI → Python API (проверено по документации spsdk)
| Сейчас (subprocess) | Станет (in-process) |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `sdphost -u ... write-file 0x20001C00 <flashloader>` | `SDP.write_file(0x20001C00, data)` |
| `sdphost -u ... jump-address 0x20001C00` | `SDP.jump_and_run(0x20001C00)` |
| `blhost -u ... get-property 1 0` (поллинг) | `McuBoot.get_property(PropertyTag.CURRENT_VERSION)` |
| `blhost ... fill-memory 0x2000 4 0xC0000007 word` | `McuBoot.fill_memory(0x2000, 4, 0xC0000007)` |
| `blhost ... configure-memory 9 0x2000` | `McuBoot.configure_memory(0x2000, mem_id=9)` |
| `blhost ... flash-erase-region 0x60000000 <size> 0` | `McuBoot.flash_erase_region(0x60000000, size)` |
| `blhost ... write-memory <addr> <file> 0` | `McuBoot.write_memory(addr, data)` |
| `blhost -t 200000 ... flash-erase-all 9` | `McuBoot.flash_erase_all(mem_id=9)` + таймаут ⚠В2 |
| `blhost ... reset` | `McuBoot.reset(reopen=False)` |
| `uv run nxpimage hab export -c <yaml> -o <bin>` | `HabImage` (пакет `spsdk.image.hab`) → `.export()`В1 |
| Детект SDP/Flashloader | `SdpUSBInterface.scan(...)` / `MbootUSBInterface.scan()` (см. Р7) |
### Р4. Потоковая модель
- `flash_backend.py` — чистый синхронный Python, **ноль** импортов
Textual/asyncio; прогресс — синхронный callback.
- Мост поток→loop живёт **внутри `Flasher`** (не в экранах):
`asyncio.to_thread(...)` + `asyncio.run_coroutine_threadsafe()`.
- Публичный API `Flasher` заморожен → `flash.py`/`waiting.py` в фазах
13 не редактируются. Главный контейнер регрессии.
- Worker-поток не трогает виджеты (грабли `self._running`/
`MessagePump` из DEV_ARCH §13 сюда не заносим).
### Р5. Отмену операций сознательно НЕ делаем
Как сейчас: кнопки блокируются `_set_busy`. Блокирующий USB-вызов из
потока корректно не прервать; обрыв кабеля backend обнаружит сам через
исключения spsdk — это и есть заявленный выигрыш V4 вместо
зомби-subprocess.
### Р6. Data-файлы и временные файлы
- Единый источник `tools/host/dcd/` (`ivt_flashloader.bin`, `dcd.bin`,
`*_fdcb.bin`) — их использует и нетронутый `flash_usb.py`. TUI
резолвит двухрежимным паттерном (dev: repo-relative; frozen: рядом
с exe через PyInstaller `datas`). Дублей блобов в репо не заводим.
- Временные HAB-файлы — в `tempfile.gettempdir()`; cwd-магия
«temp .yaml в `tools/host/hab/`» умирает вместе с subprocess (в
Python API пути абсолютные). Побочный выигрыш: frozen-бандлу не
нужна записываемая директория внутри себя.
### Р7. Детект устройств — через spsdk, `pyusb` удаляется ⚠ пересмотр закрытого решения
Пересматривает `_detect_usb` (pyusb) и вытекающее требование
Zadig/WinUSB из `RELEASE_PLAN.md` шаг 4. **Требует твоего явного
подтверждения** — после него считается принятым.
Суть: SDP BootROM (`1FC9:0130`) и Flashloader (`15A2:0073`) — это
**HID**-устройства. spsdk общается с ними через libusbsio/hidapi,
которому Zadig не нужен — именно поэтому sdphost/blhost/SPT у NXP
работают на Windows из коробки. WinUSB был нужен только нашему
pyusb-детекту; привязка WinUSB к HID-устройству вдобавок *отбирает*
его у стандартного HID-стека. Требование Zadig — самонаведённое.
Замена (фаза 1):
- `detect_sdp()``SdpUSBInterface.scan(device_id="0x1FC9:0x0130")`;
- детект Flashloader → `MbootUSBInterface.scan()`;
- `detect_cdc()` → только `serial.tools.list_ports` по VID:PID
(CDC по определению виден как COM-порт; pyusb-ветка ничего не
добавляла).
Следствия: Zadig исчезает из полевой инструкции целиком; `pyusb` и
`libusb-1.0.dll` уходят из зависимостей/бандла; вместо них в бандл
должны попасть нативные библиотеки libusbsio (гейт фазы 5).
### Р8. Резолв serial-портов: VID:PID — идентичность, имя порта — рантайм
Имена портов не переносимы даже в пределах одной ОС (перевоткнул в
другой USB-порт — имя изменилось: `cu.usbmodemXXXX` / `COMn` /
`ttyACMn`). Принцип:
```
1. Задан <NAME>_PORT в окружении → использовать as-is (escape hatch).
2. Иначе list_ports по VID:PID.
3. Одно совпадение → info.device (pyserial открывает и cu.*, и COMn,
включая COM>9, без платформенных приседаний).
4. Ноль → «не найдено» (для TUI — штатное состояние WaitingScreen).
5. Несколько → первое + warning в лог; дизамбигуация по
serial_number — задел на будущее (см. О3).
```
Реализация — новый `tools/production/app/usb_ports.py`. В shared не
выносится (прецедент Р2): HIL-стенд стационарный, пиновка портов в
`.env` там осмысленна и остаётся как есть.
### Р9. Пересмотр `.env` (контекст `tools/production`; HIL-блок не трогается)
| Переменная | Судьба |
| --------------------------------------------------------------- | -------------------------------------------- |
| `BOOTROM_VID/PID`, `FLASHLOADER_VID/PID`, `SERVICE_CDC_VID/PID` | Остаются (идентичность) |
| `SERVICE_M5_VID/PID` | Добавить (сейчас M5 идентифицируется портом) |
| `HIL_USB_CDC_PORT`, `HIL_M5_PORT` (в контексте TUI) | Необязательный override |
Принцип: production-TUI запускается на чистой машине **вообще без
`.env`** — все значения имеют fallback-константы в коде (для VID/PID
уже так). `.env` — инструмент разработчика/стенда, не артефакт рядом
с exe.
---
## 2. Матрица «USB-класс × ОС» (справочная база решений Р7/Р8)
| Устройство | VID:PID | Класс | macOS | Windows 10/11 | Linux |
| --------------------------- | ----------- | ------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| BootROM SDP | `1FC9:0130` | HID | Из коробки (IOHIDFamily) | Из коробки (`hidclass`) | Из коробки; права на `hidraw` (udev) |
| Flashloader | `15A2:0073` | HID | Из коробки | Из коробки | То же |
| firmware_test | `1996:00AD` | CDC ACM | Из коробки, `/dev/cu.usbmodem*` | Из коробки с Win10 (`usbser.sys` по классу), `COMn` | Из коробки, `/dev/ttyACM*`, dialout (рецепт `setup-m5-udev` есть) |
| M5StampPLC | см. О1 | CDC или мост | см. О1 | см. О1 | — |
| `pyusb`/libusb перечисление | — | — | Работает (текущий детект) | **Не видит без WinUSB/libusbK** (задокументировано в RELEASE_PLAN) + нужен `libusb-1.0.dll` в бандле | Работает при правах |
macOS-нюанс, снимаемый резолвером Р8 автоматически: использовать
`cu.*`, не `tty.*` (callout не ждёт DCD) — `list_ports` на macOS и
так отдаёт `cu.*`.
---
## 3. Открытые вопросы (требуют ответа/измерения до соответствующей фазы)
**О1. M5StampPLC — чем представляется хосту?** Честно: не знаю —
нативный ESP32-S3 USB CDC (VID Espressif `303A`) или мост
CH9102/CP210x. Снимается за минуту: воткнуть M5, выполнить
`python -m serial.tools.list_ports -v` → VID:PID + serial_number.
Развилка: нативный CDC → драйверы не нужны нигде, Р8 закрывает
вопрос; мост → на изолированной Windows без сети нужен один
вендорский драйвер (честная необходимость уровня ОС, в отличие от
Zadig), пункт в инструкцию сервисника + проверка на гейте 5.
**Измерение — в фазе 0.**
**О2. Состав `firmware/` в релизе v1** — унаследован из
`RELEASE_PLAN.md`: только `firmware_test` Debug (Release нестабилен)?
`bootloader`+`app` не включаем? Нужен для фазы 5, не раньше.
**О3. Несколько одинаковых устройств одновременно** — сознательно за
скобками v1: у SDP BootROM различающего серийника нет, UX
WaitingScreen рассчитан на одну плату. Фиксируется в README как
известное ограничение, не как долг. Подтверди, что объём согласован.
---
## Фаза 0 — Спайк / де-риск (без изменений в TUI)
Цель: подтвердить неизвестные API (⚠В1, ⚠В2, сигнатуры `scan()`)
и совместимость зависимостей — до первой строки боевого кода.
**Файлы:** `tools/production/pyproject.toml`, `uv.lock`,
`tools/production/spike/` (временная директория, в релиз не идёт).
1. Добавить `spsdk==3.7.0` в `tools/production`, `uv lock`/`uv sync` —
дерево (156 пакетов в `tools/host/uv.lock`) не должно конфликтовать
с `textual`/`pyserial`.
2. `spike_hab.py`: HAB из «сырого» бинарника через `HabImage` с теми же
опциями, что в `_build_custom_hab()` (`startAddress=0x60000000,
ivtOffset=0x1000, initialLoadSize=0x2000, family=mimxrt1050`;
DCD on/off). — закрывает ⚠В1.
3. `spike_flash.py`: `SdpUSBInterface.scan``SDP.write_file` +
`jump_and_run` → поллинг `McuBoot.get_property`
`configure_memory`; выяснить механизм таймаута ≥200 с для
`flash_erase_all` (эквивалент `blhost -t 200000`). — закрывает ⚠В2
и сигнатуры Р7.
4. Прогнать `spike_flash.py` на **Windows-машине без Zadig**
дешёвая ранняя проверка Р7 (детект + HID-транспорт).
5. Измерение О1 (VID:PID/serial M5 через list_ports).
### Гейт 0
- [ ] `uv lock` без конфликтов.
- [ ] **Golden-тест HAB (byte-exact):** образ из `HabImage` побайтно
равен `nxpimage hab export` с тем же конфигом, DCD on/off.
Железо не нужно. Оформить pytest'ом — остаётся навсегда как
регрессия на апгрейды spsdk.
- [ ] Железо: flashloader поднимается через Python API,
`get_property` отвечает.
- [ ] Windows без Zadig: SDP виден, flashloader грузится.
- [ ] Известен способ задать таймаут ≥200 с для erase-all.
- [ ] О1 закрыт (VID:PID зафиксирован, ветка развилки известна).
- [ ] **Стоп-условие В1:** `HabImage` не даёт byte-exact / API
непригоден → HAB остаётся subprocess-вызовом `nxpimage`
in-process; остальной монолит не страдает; фаза 3 сужается.
Решение фиксируется до старта фазы 1.
---
## Фаза 1 — Backend-модуль (синхронное ядро, без UI)
**Файлы (новые):** `tools/production/app/flash_backend.py`,
`tools/production/app/usb_ports.py`,
`tools/production/tests/test_flash_backend.py`.
**Файлы (правки):** нет — `flasher.py`, `flash.py` не трогаются.
`flash_backend.py` — прямой перенос логики `flash_usb.py` по таблице Р3:
- `detect_sdp()/detect_cdc()` — по Р7 (spsdk scan + list_ports),
pyusb-код не переносится;
- `load_flashloader(progress)` — идемпотентно, как сейчас («уже
запущен — пропускаем»), поллинг с тем же 10-секундным лимитом;
- `configure_flexspi()`, `write_fcb()`, `write_fcb_explicit(path)`
1:1 с `flash_usb.py`, включая option words `0xC0000007`/`0xF000000F`
и расчёт `erase_size` по 4K-секторам;
- `flash_image(hab_bin, fcb_path|None, progress)`, `erase_chip(progress)`;
- прогресс: `Callable[[FlashProgress], None]`, фазы — честные этапы
конвейера (`flashloader/configure/erase/fcb/write/reset`) вместо
regex-парсинга stdout. Если спайк подтвердил `progress_callback`
у записи — процент внутри `write_memory`, иначе поэтапный
(5 этапов ≈ 20% гранулярность — приемлемо);
- ошибки: доменное `FlashBackendError(phase, cause)`; внутри перехват
`SdpError`/`McuBootError`/`McuBootConnectionError`; таймаут и
«устройство пропало» различимы.
`usb_ports.py` — резолвер Р8 (`UsbId`, `resolve_serial_port`).
### Гейт 1
- [ ] Unit-тесты (без железа): мок `McuBoot`/`SDP`, сверка
последовательности команд с `flash_usb.py` как эталоном для
flash/erase/fcb-explicit; исключения → `FlashBackendError`
с корректной фазой; резолвер портов (override / одно /
ноль / несколько совпадений).
- [ ] Smoke на железе через mini-CLI (`python -m app.flash_backend`):
прошивка `firmware_test_hab.bin` (Debug), плата грузится,
текущий TUI (subprocess-версия!) видит CDC, `ping→pong`.
- [ ] Chip erase на W25Q512 укладывается в таймаут.
---
## Фаза 2 — Пересадка `Flasher` на backend (async-фасад)
**Файлы (правки):** `tools/production/app/flasher.py` — переписывается
изнутри при неизменном публичном API.
**Не трогаются:** `flash.py`, `waiting.py`, `app.py`,
`connection_watcher.py`, `models.py`.
- `flash()/erase_chip()`: вместо `uv run ...` и `_run_cmd`
`await asyncio.to_thread(backend..., ...)`; loop захватывается до
ухода в поток, прогресс пробрасывается через
`run_coroutine_threadsafe(progress_cb(p), loop)`.
- `detect_sdp()/detect_cdc()/list_custom_binaries()` — делегирование
в backend, сигнатуры и `@staticmethod` прежние.
- PRODUCTION-цепочка (bootloader → app при успехе) остаётся в
`Flasher.flash()`.
- Удаляются: `_run_cmd`, `_run_flash`, `_run_flash_bin`,
`_parse_progress`, `_RE_PERCENT`, `_RE_PHASE`, пути `uv`/скрипта.
`_run_flash_custom`/`_build_custom_hab` пока на subprocess
(мигрируют в фазе 3) — смешанный режим допустим, API этого не видит.
- Резолв `BUILD_DIR` HAB-образов переезжает в backend: dev —
`<repo>/build/<Type>/<name>_hab.bin`, frozen —
`sys.executable.parent / "firmware"` (схема из RELEASE_PLAN §3,
теперь без venv).
### Гейт 2
- [ ] `git diff` подтверждает: `flash.py` не изменён ни на строку.
- [ ] Headless Textual-тест: FlashScreen — прогресс обновляется,
кнопки блокируются/разблокируются, `FlashDone` с корректными
полями.
- [ ] Железо: полный цикл через TUI — firmware_test →
PostFlashScreen → диагностика; PRODUCTION (два образа подряд);
chip erase. Поведение визуально эквивалентно subprocess-версии.
- [ ] `#flash-log` не «зависает» на долгих этапах.
---
## Фаза 3 — Кастомные бинарники: HAB in-process + явный FCB
**Файлы (правки):** `flash_backend.py` (+`build_custom_hab()`),
`flasher.py` (custom-путь → backend).
**Не трогается:** `flash.py` (UI custom-группы готов).
- `build_custom_hab(raw_bin, use_dcd)`: конфиг формируется в памяти,
`DCDFilePath` — абсолютным путём через резолвер Р6; временный образ —
в системном tmp. Если сработало стоп-условие В1 — та же сигнатура,
внутри subprocess `nxpimage`; UI разницы не видит.
- `_run_flash_custom`: `build_custom_hab` → `flash_image(hab,
fcb_path=dcd/<variant>_fdcb.bin)` — вся цепочка in-process
(`write_fcb_explicit` готов с фазы 1).
- Golden-тест фазы 0 расширяется custom-кейсом (реальный
легаси-бинарник, DCD on/off).
### Гейт 3 (повторяет чек-лист RELEASE_PLAN шага 4 по custom-пути)
- [ ] Golden-тест HAB зелёный для custom-кейса.
- [ ] Железо W25Q128: custom, DCD off → грузится.
- [ ] Железо W25Q512: custom → грузится; якорная проверка 4-байтной
адресации (aliasing-методика) в порядке.
- [ ] Chip erase → повторная прошивка → плата живая.
- [ ] «Липкий» `FlashPreset` работает (следующая плата — выбор
подставлен).
---
## Фаза 4 — Нативная обработка отвала USB + зачистка
**Файлы (правки):** `flash_backend.py`, `flasher.py`; точечно
`flash.py`/`connection_watcher.py` — только если гейт покажет
необходимость (по умолчанию нет).
- Обрыв посреди операции: `McuBootConnectionError`/таймауты →
`FlashBackendError(..., connection_lost=True)``Flasher` возвращает
`False` + финальный `FlashProgress(phase="error")` с
человекочитаемым сообщением. Схема с `ConnectionWatcherMixin`
прежняя: во время `_flashing` watcher приглушён, обрыв репортит сам
backend — то, что раньше делал subprocess, без зомби-процессов.
- Ревизия ресурсов: USB-интерфейсы закрываются в
`finally`/context-manager'ах при любом исходе (утечка HID-хэндла —
классическая причина «device busy» при повторе).
- Зачистка: следов subprocess-эры, `uv`, путей `flash_usb.py`,
`_HAB_DIR`-магии в `tools/production/` не остаётся.
### Гейт 4 (деструктивные сценарии на железе)
- [ ] Выдернуть USB во время `write-memory` → ошибка в TUI, возврат
на WaitingScreen, повторная вставка → повторная прошивка
успешна (порт не «занят»).
- [ ] Выдернуть во время chip erase (W25Q512) → то же; выход из
приложения чистый, подвисших потоков нет.
- [ ] Выдернуть в простое на FlashScreen → срабатывает watcher
(регрессия старого пути).
- [ ] `grep -r "flash_usb\|uv run\|subprocess\|usb.core" \
tools/production/app/` — пусто.
---
## Фаза 5 — Упаковка PyInstaller (замена шага 3 RELEASE_PLAN)
**Файлы (новые):** `tools/production/service_tui.spec`, рецепты в
`just/ci.just` или `host.just` (по месту; имена задач согласуем
отдельно, не изобретаю).
```
service-tui-vX.Y.Z-<os>/
├── service_tui[.exe] ← PyInstaller, onedir (onefile на Windows
│ замедляет старт распаковкой — не берём)
├── _internal/ ← рантайм PyInstaller
│ └── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin
│ (datas из tools/host/dcd/), data spsdk
├── firmware/
│ └── Debug/firmware_test_hab.bin (состав — см. О2)
└── custom_binaries/ ← пустая, создаётся и так
```
Ключевые пункты spec:
- `collect_data_files("spsdk")` (+ при необходимости
`SPSDK_DATA_FOLDER` — документированный NXP механизм для frozen);
- **`collect_dynamic_libs("libusbsio")`** — нативный HID-транспорт
spsdk (следствие Р7); `libusb-1.0.*` в бандле отсутствует;
- `datas`: `tools/host/dcd/{dcd.bin, w25q128_fdcb.bin,
w25q512_fdcb.bin, ivt_flashloader.bin}` → `data/`;
- резолвер путей backend'а: frozen → `data/` рядом с exe;
- версия: `_read_app_version()` (tomllib) — `pyproject.toml` в
`datas`, проверить чтение во frozen.
### Гейт 5 (замена шага 4 RELEASE_PLAN; Windows + macOS)
- [ ] Чистая Windows-машина, **без Zadig, без сети, без Python/uv**:
полный полевой цикл — детект SDP → firmware_test → диагностика →
custom (128 и 512) → chip erase.
- [ ] Если О1 = мост: установка одного вендорского драйвера по
инструкции, M5-функции работают.
- [ ] То же на macOS (в ветке «мост» — проверить и там).
- [ ] Версия на WaitingScreen корректна во frozen.
- [ ] Порты резолвятся при перетыкании в другой физический USB-порт
(проверка Р8 на обеих ОС).
---
## Фаза 6 — Документация, CHANGELOG, релиз (шаги 56 RELEASE_PLAN)
**Файлы:** `CHANGELOG.md`; `RELEASE_PLAN.md` (закрыть шаг 3 ссылкой
сюда); `docs/DEV_ARCH.md` (§2 — убрать `subprocess uv run` из
диаграммы, §8.3 — новый конвейер); `HOW_TO_FLASH.md`; `README.md`
`tools/production`; `.env.example` (по Р9).
- CHANGELOG: монолит (flash_backend, отказ от venv/subprocess),
нативный детект без Zadig, кроссплатформенный резолв портов,
нативная обработка отвала USB, упаковка одним exe.
- Zadig-инструкция в доки **не добавляется** (RELEASE_PLAN планировал
добавить — отменено по Р7); при ветке О1-«мост» — добавляется
инструкция по одному вендорскому драйверу.
- Зафиксировать разделение: `flash_usb.py` — dev-CLI (just-рецепты),
`flash_backend.py` — production-TUI; независимые реализации по
прецеденту M5-клиентов (Р2).
- Golden-тест HAB — обязательный при апгрейде spsdk.
- Ограничение «одна плата на столе» (О3) — в README.
- Тег релиза = версия из `pyproject.toml`.
### Гейт 6
- [ ] Документация синхронизирована (железо подтверждено гейтами 35).
- [ ] `just host::flash*`, `incoming`, `production` работают как
раньше — регрессия dev-пути.
- [ ] Релизный артефакт собран из тега; чек-лист гейта 5 повторён на
релизном бинаре.
---
## Сводка рисков и trade-offs
| Риск / trade-off | Фаза | Митигация / цена |
| --------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------- |
| `HabImage` в 3.7.0 не byte-exact / непригоден | 0 | Стоп-условие В1: HAB остаётся subprocess in-process; монолит не страдает |
| Таймаут `flash_erase_all` (W25Q512) | 0 | Спайк ⚠В2 до боевого кода |
| Сигнатуры `scan()` / поведение HID-скана на Windows | 0 | Спайк на Windows без Zadig |
| Гранулярность прогресса хуже stdout-парсинга | 12 | Поэтапный прогресс (5 фаз); честнее текущего — `flash_usb.py` процентов фактически не печатает, бар и сейчас живёт на фазах |
| Утечка USB-хэндла → «device busy» | 4 | Context-managers + деструктивный гейт 4 |
| PyInstaller: data/hooks spsdk, нативные libusbsio | 5 | Документированный NXP путь + `SPSDK_DATA_FOLDER` + `collect_dynamic_libs`; риск смещён на CI, не в поле |
| Textual-грабли при потоках | 2 | Мост изолирован во `Flasher`; экраны не трогаются до фазы 4 |
| Отказ от отмены операций (Р5) | — | Цена: UX как сегодня (ждать до конца/обрыва); выигрыш: нет некорректного прерывания USB-транзакций |
| Первое-из-нескольких при дубликатах устройств (О3) | — | Warning в лог; serial_number-дизамбигуация — задел |
## Порядок ревью
По практике проекта — один файл за раз; полные файлы там, где файл
новый или переписывается целиком (`flash_backend.py`, `usb_ports.py`,
`flasher.py`), unified diff — для точечных правок (`pyproject.toml`,
документация, `.env.example`).
**Старт (после подтверждения Р7 и, желательно, измерения О1):**
фаза 0 — diff `pyproject.toml` + `spike_hab.py` + `spike_flash.py`.

View file

@ -1,128 +0,0 @@
# service-tui — план первого релиза
> Не путать с `TUI_PLAN.md` (бэклог фич UI — экспорт результатов в JSON,
> копирование UID) — этот документ про выпуск текущей версии, а не про
> новые возможности. Если в проекте уже есть `PLAN.md`/`TUI_PLAN.md` —
> имеет смысл свести их в один файл; пока создан отдельно, чтобы не
> перезаписать вслепую то, чего я не видел.
Ветка разработки: `feature-tui-python``dev`. Особенностей процесса
(review/CI-гейты) нет.
---
## 1. Слияние `feature-tui-python``dev`
Ничем не заблокировано — документация синхронизирована, полевые прогоны
идут без блокирующих багов. Выполняется отдельно от вырезки релиза (шаг 6).
**Статус:** готово.
---
## 2. `CHANGELOG.md`
Запись по всему циклу этого треда: custom-бинарники (`custom_binaries/`,
`FcbVariant`, явная запись FCB вместо auto-config), sticky-выбор
(`FlashPreset`), CSS-фикс переполнения `FlashScreen`, увеличенный таймаут
`flash-erase-all` для W25Q512.
**Статус:** готово.
---
## 3. Упаковка приложения
**Решение:** вариант **B** — предсобранный `tools_host/` (venv со spsdk)
кладётся рядом с exe в релизном архиве, без необходимости `uv`/сети на
машине сервисника.
### Layout архива
```
service-tui-vX.Y.Z-<os>/
├── service_tui[.exe] ← PyInstaller
├── firmware/
│ └── Debug/firmware_test_hab.bin
├── custom_binaries/ ← пустая, создаётся и так
└── tools_host/ ← предсобранный venv + flash_usb.py, dcd/
├── .venv/
├── flash_usb.py
├── dcd/
└── pyproject.toml / uv.lock
```
### Код — `flasher.py`
Два режима резолва пути/интерпретатора `flash_usb.py`, по аналогии с уже
реализованным резолвом `custom_binaries/`:
```python
if getattr(sys, "frozen", False):
_HOST_TOOLS_DIR = Path(sys.executable).resolve().parent / "tools_host"
_PYTHON = _HOST_TOOLS_DIR / ".venv" / (
"Scripts/python.exe" if os.name == "nt" else "bin/python"
)
cmd = [str(_PYTHON), str(_HOST_TOOLS_DIR / "flash_usb.py"), ...]
else:
_HOST_TOOLS_DIR = Path(__file__).parents[3] / "tools" / "host"
cmd = ["uv", "run", "--directory", str(_HOST_TOOLS_DIR), "python", "flash_usb.py", ...]
```
`BUILD_DIR` (штатные `firmware_test`/`bootloader`/`app`) — та же логика:
frozen-режим по умолчанию резолвит `sys.executable.parent / "firmware"`,
dev-режим — как сейчас (`<repo_root>/build`).
### Открытые вопросы
- **Состав `firmware/` в v1** — предполагается только `firmware_test`
**Debug** (Release помечен нестабильным; `firmware_test` — единственный
образ, непосредственно нужный для диагностики). `bootloader`+`app`
(Production) в первый релиз не включены — требует подтверждения.
- **Переносимость venv между машинами** — если в `spsdk` есть нативные
компоненты (не чистый Python), venv, собранный на CI/машине разработчика,
может не завестись на машине сервисника с другой версией ОС/libc.
Требует проверки на шаге 4, а не предположения.
**Статус:** не начато.
---
## 4. Тестирование на отдельном хосте (Windows)
**Известный риск (не блокер, но обязательный шаг перед раздачей):**
`pyusb` на Windows не видит устройство без явно привязанного драйвера
(WinUSB/libusbK). Без этого `Flasher.detect_sdp()` молча возвращает `False`
и `WaitingScreen` никогда не поймает плату в SDP-режиме — фолбэк на
`serial.tools.list_ports` не спасает, у SDP нет serial-порта в принципе.
**Обязательные подготовительные действия на тестовой машине:**
1. Через **Zadig** привязать WinUSB к `1FC9:0130` (BootROM SDP)
2. Через **Zadig** привязать WinUSB к `15A2:0073` (Flashloader)
3. Это нужно занести в инструкцию для сервисника (`README.md`/`HOW_TO_FLASH.md`),
не держать только в голове — иначе на полевом Windows-ноутбуке повторится
та же засада молча.
**Также проверить на этом шаге:**
- Переносимость `tools_host/.venv` (см. открытый вопрос шага 3)
- Полный цикл: прошивка firmware_test → диагностика → custom-бинарник (128/512) → chip erase
**Статус:** не начато, ждёт шага 3.
---
## 5. Доработки по необходимости
Резерв на то, что найдётся на шаге 4. Содержание заранее не известно —
пункт-заглушка, наполняется по факту тестирования.
---
## 6. Первый релиз
Состав: `service-tui` (упакован по схеме шага 3) + `firmware_test` (Debug,
см. открытый вопрос шага 3). Версия TUI берётся из `pyproject.toml`
(`_read_app_version()`, уже используется на `WaitingScreen`) — тег релиза
предлагается синхронизировать с этим значением.
**Статус:** не начато, ждёт шагов 15.

View file

@ -1,467 +0,0 @@
# 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 для точечных правок.