From d7dd9aea4ac305727ac7256733d3c90a3a91486a Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Mon, 6 Jul 2026 10:01:10 +0300 Subject: [PATCH] # Phase 4: connection loss handling with spsdk - MacOS tested --- tools/production/app/flash_backend.py | 59 ++- .../{RELEASE_PLAN.md => RELEASE_PLAN_v1.md} | 0 tools/production/docs/RELEASE_PLAN_v2.md | 467 ++++++++++++++++++ tools/production/docs/TUI_PLAN.md | 8 - tools/production/tests/test_flash_backend.py | 122 ++++- 5 files changed, 634 insertions(+), 22 deletions(-) rename tools/production/docs/{RELEASE_PLAN.md => RELEASE_PLAN_v1.md} (100%) create mode 100644 tools/production/docs/RELEASE_PLAN_v2.md delete mode 100644 tools/production/docs/TUI_PLAN.md diff --git a/tools/production/app/flash_backend.py b/tools/production/app/flash_backend.py index 99c9047..b7f3611 100644 --- a/tools/production/app/flash_backend.py +++ b/tools/production/app/flash_backend.py @@ -39,6 +39,7 @@ from spsdk.image.hab.hab_image import HabImage from spsdk.mboot import McuBoot, MbootUSBInterface from spsdk.sdp import SDP, SdpUSBInterface from spsdk.utils.config import Config +from spsdk.utils.exceptions import SPSDKTimeoutError from .models import FlashProgress from .usb_ports import UsbId, resolve_serial_port @@ -155,6 +156,15 @@ class HabBuildError(FlashBackendError): """Ошибка сборки HAB-образа через HabImage (см. build_custom_hab).""" +# SPSDKTimeoutError НЕ наследует SPSDKConnectionError (оба — потомки SPSDKError, +# проверено по исходникам spsdk 3.7.0), поэтому один `except SPSDKConnectionError` +# его пропускал → safety net в Flasher показывал «Непредвиденная ошибка» вместо +# «Соединение потеряно» (Фаза 4a). Ловим оба явным кортежем в SDP/McuBoot-обёртках: +# read-фаза после write может отдать голый таймаут. Именованная константа вместо +# 4× инлайн-дублей. +_CONNECTION_LOST_EXCEPTIONS = (SPSDKConnectionError, SPSDKTimeoutError) + + # ─── Detection (Р7 — spsdk API вместо pyusb) ─────────────────────────────── @@ -168,6 +178,35 @@ def detect_cdc() -> bool: return resolve_serial_port(UsbId(_CDC_VID, _CDC_PID)) is not None +def _sdp_still_present() -> bool: + """Быстрая проверка «плата ещё на шине» для error-путей (вариант B, Р10). + + Любая ошибка самой проверки трактуется как «устройства нет»: проверка + выполняется только ПОСЛЕ уже случившегося сбоя команды, шина в этот + момент нестабильна, и «не смог проверить» практически всегда означает + «плату выдернули» (согласовано, RELEASE_ROADMAP.md §B). + """ + try: + return detect_sdp() + except Exception: # noqa: BLE001 — см. docstring: любой сбой ⇒ считаем обрывом + return False + + +def _fail_command(message: str) -> None: + """Живая команда spsdk вернула False — переклассификация по варианту B (Р10). + + Если устройство пропало с шины → обрыв (ConnectionLostError), иначе → + честная ошибка операции (FlashBackendError с прежним текстом). detect_sdp() + выполняется ТОЛЬКО здесь, в error-пути; happy path не затрагивается. + + :raises ConnectionLostError: устройство исчезло с шины после сбоя команды. + :raises FlashBackendError: устройство на месте — ошибка самой операции. + """ + if not _sdp_still_present(): + raise ConnectionLostError(f"{message} (устройство пропало с шины)") + raise FlashBackendError(message) + + def _emit( progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str ) -> None: @@ -244,7 +283,7 @@ def load_flashloader( with SDP(sdp_devices[0]) as sdp: sdp.write_file(FLASHLOADER_LOAD_ADDR, data) sdp.jump_and_run(FLASHLOADER_LOAD_ADDR) - except SPSDKConnectionError as exc: + except _CONNECTION_LOST_EXCEPTIONS as exc: raise ConnectionLostError( f"USB-соединение потеряно при загрузке Flashloader: {exc}" ) from exc @@ -262,7 +301,7 @@ def configure_flexspi(mboot: McuBoot) -> None: mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE) ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) if not ok: - raise FlashBackendError("configure_memory (FlexSPI init) вернул False") + _fail_command("configure_memory (FlexSPI init) вернул False") def write_fcb_auto(mboot: McuBoot) -> None: @@ -273,7 +312,7 @@ def write_fcb_auto(mboot: McuBoot) -> None: mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_FCB_VALUE) ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) if not ok: - raise FlashBackendError("configure_memory (FCB write) вернул False") + _fail_command("configure_memory (FCB write) вернул False") def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: @@ -287,7 +326,7 @@ def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: data = fcb_path.read_bytes() ok = mboot.write_memory(FLASH_BASE, data, mem_id=0) if not ok: - raise FlashBackendError(f"write_memory(FCB {fcb_path.name}) вернул False") + _fail_command(f"write_memory(FCB {fcb_path.name}) вернул False") # ─── Прошивка / RAM-load / erase ──────────────────────────────────────────── @@ -323,7 +362,7 @@ def flash( with SDP(sdp_devices[0]) as sdp: sdp.write_file(addr, hab_bin.read_bytes()) sdp.jump_and_run(addr) - except SPSDKConnectionError as exc: + except _CONNECTION_LOST_EXCEPTIONS as exc: raise ConnectionLostError( f"USB-соединение потеряно при RAM-загрузке: {exc}" ) from exc @@ -352,7 +391,7 @@ def flash( ) ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) if not ok: - raise FlashBackendError("flash_erase_region вернул False") + _fail_command("flash_erase_region вернул False") _emit(progress_cb, "fcb", 0, "Запись FCB") if fcb_path is not None: @@ -371,11 +410,11 @@ def flash( write_addr, data, mem_id=0, progress_callback=_on_progress ) if not ok: - raise FlashBackendError("write_memory (HAB-образ) вернул False") + _fail_command("write_memory (HAB-образ) вернул False") _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) - except SPSDKConnectionError as exc: + except _CONNECTION_LOST_EXCEPTIONS as exc: raise ConnectionLostError( f"USB-соединение потеряно во время прошивки: {exc}" ) from exc @@ -402,11 +441,11 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: iface.device.timeout = ERASE_ALL_TIMEOUT_MS # см. Фазу 0, ⚠В2 ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID) if not ok: - raise FlashBackendError("flash_erase_all вернул False") + _fail_command("flash_erase_all вернул False") _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) - except SPSDKConnectionError as exc: + except _CONNECTION_LOST_EXCEPTIONS as exc: raise ConnectionLostError( f"USB-соединение потеряно во время chip erase: {exc}" ) from exc diff --git a/tools/production/docs/RELEASE_PLAN.md b/tools/production/docs/RELEASE_PLAN_v1.md similarity index 100% rename from tools/production/docs/RELEASE_PLAN.md rename to tools/production/docs/RELEASE_PLAN_v1.md diff --git a/tools/production/docs/RELEASE_PLAN_v2.md b/tools/production/docs/RELEASE_PLAN_v2.md new file mode 100644 index 0000000..3ccee56 --- /dev/null +++ b/tools/production/docs/RELEASE_PLAN_v2.md @@ -0,0 +1,467 @@ +# service-tui — Roadmap до релиза v1 (фазы 4a → 4b → 5 → 6) + +> Продолжение `MONOLITH_APP_PLAN.md` (V4). Фазы 0–3 закрыты, Фаза 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//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-/ +├── service_tui[.exe] +├── _internal/ +│ ├── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin, spsdk data +│ └── ... ← рантайм PyInstaller, libusbsio +├── firmware/ +│ └── /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-` (из плана) | + +--- + +## Риски этого этапа + +| Риск | Фаза | Митигация | +| --- | --- | --- | +| `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 для точечных правок. diff --git a/tools/production/docs/TUI_PLAN.md b/tools/production/docs/TUI_PLAN.md deleted file mode 100644 index 1463e00..0000000 --- a/tools/production/docs/TUI_PLAN.md +++ /dev/null @@ -1,8 +0,0 @@ -# TUI Service Tool Plan - -1. Навести порядок с экранами "USB-POLL", "TRANSFER", возможно сделать bootlogo с версией программы и лого компании -2. Уменьшить размер активной области до предела и сделать его равным для всех экранов -3. Добавить обратный отчет в тесте buttons (таймаут) -4. Выгрузка результатов тестов в json файл с привязкой к UID микроконтроллера -5. Возможно ли копировать UID с экрана терминала? -6. Проверить работоспособность HIL тестов (CAN & Opto) diff --git a/tools/production/tests/test_flash_backend.py b/tools/production/tests/test_flash_backend.py index ef2677d..b6217e9 100644 --- a/tools/production/tests/test_flash_backend.py +++ b/tools/production/tests/test_flash_backend.py @@ -1,5 +1,6 @@ """ test_flash_backend.py — юнит-тесты app/flash_backend.py. +uv run pytest tests/test_flash_backend.py -v Два уровня: 1. SDP/McuBoot/detect — моки (unittest.mock через monkeypatch). Железо не @@ -160,10 +161,14 @@ def test_configure_flexspi_ok(): ) -def test_configure_flexspi_fail(): +def test_configure_flexspi_fail(monkeypatch): + # Плата на месте → честная ошибка операции, НЕ обрыв (вариант B, Фаза 4a). + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) mboot = Mock(configure_memory=Mock(return_value=False)) - with pytest.raises(fb.FlashBackendError): + with pytest.raises(fb.FlashBackendError) as ei: fb.configure_flexspi(mboot) + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False def test_write_fcb_auto_uses_magic_word(): @@ -190,13 +195,15 @@ def test_write_fcb_explicit_ok(tmp_path): mboot.write_memory.assert_called_once_with(fb.FLASH_BASE, b"\xab" * 512, mem_id=0) -def test_write_fcb_explicit_write_fails(tmp_path): +def test_write_fcb_explicit_write_fails(monkeypatch, tmp_path): + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) fcb = tmp_path / "w25q128_fdcb.bin" fcb.write_bytes(b"\xab" * 512) mboot = Mock(write_memory=Mock(return_value=False)) - with pytest.raises(fb.FlashBackendError): + with pytest.raises(fb.FlashBackendError) as ei: fb.write_fcb_explicit(mboot, fcb) + assert ei.value.connection_lost is False # ─── flash() ────────────────────────────────────────────────────────────── @@ -309,6 +316,9 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path): hab_bin = tmp_path / "fw_hab.bin" hab_bin.write_bytes(b"\xd1") + monkeypatch.setattr( + fb, "detect_sdp", Mock(return_value=True) + ) # плата на месте → честная ошибка monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) _mock_mcuboot_ctx(monkeypatch, write_memory=False) @@ -361,6 +371,9 @@ def test_erase_chip_happy_path(monkeypatch, events): def test_erase_chip_fails(monkeypatch, events): + monkeypatch.setattr( + fb, "detect_sdp", Mock(return_value=True) + ) # плата на месте → честная ошибка monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock())) _mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) @@ -446,3 +459,104 @@ def test_firmware_hab_path_frozen(monkeypatch, tmp_path): monkeypatch.setattr(fb.sys, "executable", str(exe)) p = fb.firmware_hab_path("firmware_test", "Debug") assert p == exe.parent / "firmware" / "Debug" / "firmware_test_hab.bin" + + +# ─── Фаза 4a: типизация обрыва USB ─────────────────────────────────────── +# +# Три проявления обрыва (RELEASE_ROADMAP.md): +# 1) SDP/McuBoot бросают SPSDKConnectionError — ловилось и до 4a; +# 2) read-фаза после write бросает SPSDKTimeoutError — НЕ ловилось (баг); +# 3) команда возвращает False по таймауту (erase/write) — вариант B (Р10). + + +def test_connection_lost_tuple_covers_timeout(): + """Инвариант, на котором держится вся 4a: SPSDKTimeoutError НЕ потомок + SPSDKConnectionError, поэтому обязан быть в кортеже явно. Страховка от + случайного регресса при апгрейде spsdk.""" + assert fb.SPSDKTimeoutError in fb._CONNECTION_LOST_EXCEPTIONS + assert not issubclass(fb.SPSDKTimeoutError, fb.SPSDKConnectionError) + + +def test_sdp_still_present_swallows_check_error(monkeypatch): + """§B: ошибка самой проверки detect_sdp() трактуется как «устройства нет».""" + monkeypatch.setattr(fb, "detect_sdp", Mock(side_effect=RuntimeError("bus gone"))) + assert fb._sdp_still_present() is False + + +def test_fail_command_device_gone_is_connection_lost(monkeypatch): + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=False)) + with pytest.raises(fb.ConnectionLostError) as ei: + fb._fail_command("flash_erase_all вернул False") + assert ei.value.connection_lost is True + + +def test_fail_command_device_present_is_plain_error(monkeypatch): + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) + with pytest.raises(fb.FlashBackendError) as ei: + fb._fail_command("flash_erase_all вернул False") + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False + + +def test_load_flashloader_timeout_is_connection_lost(monkeypatch): + """SPSDKTimeoutError из SDP-обмена → ConnectionLostError (обёртка 4a).""" + monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[])) + monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()])) + monkeypatch.setattr( + fb, + "FLASHLOADER_BIN", + Mock( + exists=Mock(return_value=True), read_bytes=Mock(return_value=b"\x00" * 16) + ), + ) + sdp_ctx = MagicMock() + sdp_ctx.__enter__.return_value = sdp_ctx + sdp_ctx.write_file.side_effect = fb.SPSDKTimeoutError() + monkeypatch.setattr(fb, "SDP", Mock(return_value=sdp_ctx)) + + with pytest.raises(fb.ConnectionLostError): + fb.load_flashloader() + + +def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_path): + """Гейт 4a #1: SPSDKTimeoutError из write_memory → ConnectionLostError, + а не safety-net «Непредвиденная ошибка».""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 63) + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + ctx = _mock_mcuboot_ctx(monkeypatch) + ctx.write_memory.side_effect = fb.SPSDKTimeoutError() + + with pytest.raises(fb.ConnectionLostError) as ei: + fb.flash(hab_bin, progress_cb=_collector(events)) + assert ei.value.connection_lost is True + assert "reset" not in _phases(events) + assert "done" not in _phases(events) + + +def test_flash_write_false_device_gone_is_connection_lost( + monkeypatch, events, tmp_path +): + """Вариант B в flash(): write_memory=False + плата пропала → ConnectionLostError.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1") + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + _mock_mcuboot_ctx(monkeypatch, write_memory=False) + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=False)) + + with pytest.raises(fb.ConnectionLostError): + fb.flash(hab_bin, progress_cb=_collector(events)) + + +def test_erase_chip_false_device_gone_is_connection_lost(monkeypatch, events): + """Гейт 4a #2a: flash_erase_all=False + detect_sdp=False → ConnectionLostError.""" + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock())) + _mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) + monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=False)) + + with pytest.raises(fb.ConnectionLostError) as ei: + fb.erase_chip(progress_cb=_collector(events)) + assert ei.value.connection_lost is True + assert "done" not in _phases(events)