# 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.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

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