# Phase 4: connection loss handling with spsdk - MacOS tested

This commit is contained in:
Dmitry Akimov 2026-07-06 10:01:10 +03:00
parent 234f1a60cf
commit d7dd9aea4a
5 changed files with 634 additions and 22 deletions

View file

@ -39,6 +39,7 @@ from spsdk.image.hab.hab_image import HabImage
from spsdk.mboot import McuBoot, MbootUSBInterface from spsdk.mboot import McuBoot, MbootUSBInterface
from spsdk.sdp import SDP, SdpUSBInterface from spsdk.sdp import SDP, SdpUSBInterface
from spsdk.utils.config import Config from spsdk.utils.config import Config
from spsdk.utils.exceptions import SPSDKTimeoutError
from .models import FlashProgress from .models import FlashProgress
from .usb_ports import UsbId, resolve_serial_port from .usb_ports import UsbId, resolve_serial_port
@ -155,6 +156,15 @@ class HabBuildError(FlashBackendError):
"""Ошибка сборки HAB-образа через HabImage (см. build_custom_hab).""" """Ошибка сборки 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) ─────────────────────────────── # ─── 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 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( def _emit(
progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str
) -> None: ) -> None:
@ -244,7 +283,7 @@ def load_flashloader(
with SDP(sdp_devices[0]) as sdp: with SDP(sdp_devices[0]) as sdp:
sdp.write_file(FLASHLOADER_LOAD_ADDR, data) sdp.write_file(FLASHLOADER_LOAD_ADDR, data)
sdp.jump_and_run(FLASHLOADER_LOAD_ADDR) sdp.jump_and_run(FLASHLOADER_LOAD_ADDR)
except SPSDKConnectionError as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно при загрузке Flashloader: {exc}" f"USB-соединение потеряно при загрузке Flashloader: {exc}"
) from exc ) from exc
@ -262,7 +301,7 @@ def configure_flexspi(mboot: McuBoot) -> None:
mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE) mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE)
ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID)
if not ok: if not ok:
raise FlashBackendError("configure_memory (FlexSPI init) вернул False") _fail_command("configure_memory (FlexSPI init) вернул False")
def write_fcb_auto(mboot: McuBoot) -> None: 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) mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_FCB_VALUE)
ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID)
if not ok: 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: 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() data = fcb_path.read_bytes()
ok = mboot.write_memory(FLASH_BASE, data, mem_id=0) ok = mboot.write_memory(FLASH_BASE, data, mem_id=0)
if not ok: 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 ──────────────────────────────────────────── # ─── Прошивка / RAM-load / erase ────────────────────────────────────────────
@ -323,7 +362,7 @@ def flash(
with SDP(sdp_devices[0]) as sdp: with SDP(sdp_devices[0]) as sdp:
sdp.write_file(addr, hab_bin.read_bytes()) sdp.write_file(addr, hab_bin.read_bytes())
sdp.jump_and_run(addr) sdp.jump_and_run(addr)
except SPSDKConnectionError as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно при RAM-загрузке: {exc}" f"USB-соединение потеряно при RAM-загрузке: {exc}"
) from exc ) from exc
@ -352,7 +391,7 @@ def flash(
) )
ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0)
if not ok: if not ok:
raise FlashBackendError("flash_erase_region вернул False") _fail_command("flash_erase_region вернул False")
_emit(progress_cb, "fcb", 0, "Запись FCB") _emit(progress_cb, "fcb", 0, "Запись FCB")
if fcb_path is not None: if fcb_path is not None:
@ -371,11 +410,11 @@ def flash(
write_addr, data, mem_id=0, progress_callback=_on_progress write_addr, data, mem_id=0, progress_callback=_on_progress
) )
if not ok: if not ok:
raise FlashBackendError("write_memory (HAB-образ) вернул False") _fail_command("write_memory (HAB-образ) вернул False")
_emit(progress_cb, "reset", 0, "Reset") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) mboot.reset(reopen=False)
except SPSDKConnectionError as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно во время прошивки: {exc}" f"USB-соединение потеряно во время прошивки: {exc}"
) from 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 iface.device.timeout = ERASE_ALL_TIMEOUT_MS # см. Фазу 0, ⚠В2
ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID) ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID)
if not ok: if not ok:
raise FlashBackendError("flash_erase_all вернул False") _fail_command("flash_erase_all вернул False")
_emit(progress_cb, "reset", 0, "Reset") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) mboot.reset(reopen=False)
except SPSDKConnectionError as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно во время chip erase: {exc}" f"USB-соединение потеряно во время chip erase: {exc}"
) from exc ) from exc

View file

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

View file

@ -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)

View file

@ -1,5 +1,6 @@
""" """
test_flash_backend.py юнит-тесты app/flash_backend.py. test_flash_backend.py юнит-тесты app/flash_backend.py.
uv run pytest tests/test_flash_backend.py -v
Два уровня: Два уровня:
1. SDP/McuBoot/detect моки (unittest.mock через monkeypatch). Железо не 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)) mboot = Mock(configure_memory=Mock(return_value=False))
with pytest.raises(fb.FlashBackendError): with pytest.raises(fb.FlashBackendError) as ei:
fb.configure_flexspi(mboot) 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(): 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) 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 = tmp_path / "w25q128_fdcb.bin"
fcb.write_bytes(b"\xab" * 512) fcb.write_bytes(b"\xab" * 512)
mboot = Mock(write_memory=Mock(return_value=False)) 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) fb.write_fcb_explicit(mboot, fcb)
assert ei.value.connection_lost is False
# ─── flash() ────────────────────────────────────────────────────────────── # ─── flash() ──────────────────────────────────────────────────────────────
@ -309,6 +316,9 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path):
hab_bin = tmp_path / "fw_hab.bin" hab_bin = tmp_path / "fw_hab.bin"
hab_bin.write_bytes(b"\xd1") hab_bin.write_bytes(b"\xd1")
monkeypatch.setattr(
fb, "detect_sdp", Mock(return_value=True)
) # плата на месте → честная ошибка
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
_mock_mcuboot_ctx(monkeypatch, write_memory=False) _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): 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())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock()))
_mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) _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)) monkeypatch.setattr(fb.sys, "executable", str(exe))
p = fb.firmware_hab_path("firmware_test", "Debug") p = fb.firmware_hab_path("firmware_test", "Debug")
assert p == exe.parent / "firmware" / "Debug" / "firmware_test_hab.bin" 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)