From 234f1a60cf689ec4f4c613248d3c44cdb44fdaee Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Fri, 3 Jul 2026 17:19:16 +0300 Subject: [PATCH] # Phase 4: connection loss handling with spsdk --- tools/production/app/TFT_BOOTLOADER_OLD.bin | Bin 0 -> 47432 bytes tools/production/app/flash_backend.py | 192 +++++++++++++----- tools/production/app/flasher.py | 68 +++++-- tools/production/app/models.py | 3 +- tools/production/app/screens/flash.py | 5 +- .../firmware_test_hab.bin | Bin tools/production/spike/spike_readback.py | 70 +++++++ tools/production/tests/test_flash_backend.py | 25 +++ 8 files changed, 301 insertions(+), 62 deletions(-) create mode 100644 tools/production/app/TFT_BOOTLOADER_OLD.bin rename tools/production/{app => custom_binaries}/firmware_test_hab.bin (100%) create mode 100644 tools/production/spike/spike_readback.py diff --git a/tools/production/app/TFT_BOOTLOADER_OLD.bin b/tools/production/app/TFT_BOOTLOADER_OLD.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5a469290f8312405ba9678b4fc812dd15610daf GIT binary patch literal 47432 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 SCNANwBw literal 0 HcmV?d00001 diff --git a/tools/production/app/flash_backend.py b/tools/production/app/flash_backend.py index d527d8b..99c9047 100644 --- a/tools/production/app/flash_backend.py +++ b/tools/production/app/flash_backend.py @@ -9,12 +9,18 @@ Zero Textual/asyncio импортов — модуль полностью син без event loop (см. test_flash_backend.py). Async-обвязка (asyncio.to_thread + run_coroutine_threadsafe) — забота Flasher (Фаза 2), не этого модуля. -Открытые вопросы/допущения (см. сопроводительное сообщение в чате): +Открытые вопросы/допущения: - Одна сессия McuBoot на весь flash()/erase_chip(), а не переоткрытие на каждую операцию, как в CLI flash_usb.py. - Имена FlashProgress.phase свои (не парсинг stdout blhost). - - Иерархия исключений — предложение автора модуля, не зафиксирована - отдельно в MONOLITH_APP_PLAN.md. + +Фаза 4: SPSDKConnectionError оборачивается в ConnectionLostError на всех +трёх точках отказа (SDP write, McuBoot handshake+команды, McuBoot chip erase), +что позволяет Flasher/TUI отличить обрыв USB от логической ошибки через +поле connection_lost. USB-интерфейс, полученный из load_flashloader(), +закрывается в finally на любом исходе (защита от утечки HID-хэндла в +редком окне «wait_for_flashloader вернул интерфейс → USB выдернут → +McuBoot.__enter__ упал»). """ from __future__ import annotations @@ -22,11 +28,13 @@ from __future__ import annotations import hashlib import logging import os +import sys import tempfile import time from pathlib import Path from typing import Callable, Optional +from spsdk.exceptions import SPSDKConnectionError from spsdk.image.hab.hab_image import HabImage from spsdk.mboot import McuBoot, MbootUSBInterface from spsdk.sdp import SDP, SdpUSBInterface @@ -52,6 +60,15 @@ def fcb_blob_path(fcb_filename: str) -> Path: return _HOST_DCD_DIR / fcb_filename +def firmware_hab_path(firmware: str, build_type: str) -> Path: + """Путь к готовому HAB-образу штатной прошивки (Р6, двухрежимный резолв).""" + if getattr(sys, "frozen", False): + base = Path(sys.executable).resolve().parent / "firmware" + else: + base = Path(os.environ.get("BUILD_DIR", str(REPO_ROOT / "build"))) + return base / build_type / f"{firmware}_hab.bin" + + # ─── USB VID:PID (те же переменные окружения, что уже приняты в проекте) ── _BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16) @@ -92,7 +109,38 @@ _HAB_OPTIONS_TEMPLATE = [ class FlashBackendError(Exception): - """Базовая ошибка flash_backend.""" + """Базовая ошибка flash_backend. + + :cvar connection_lost: True если ошибка связана с потерей физического + соединения (USB выдернут, устройство пропало с шины). Позволяет + Flasher/TUI отличить обрыв от логической ошибки без парсинга + текста сообщения. По умолчанию False; подкласс ConnectionLostError + переопределяет на True. + """ + + connection_lost: bool = False + + def __init__(self, message: str, *, connection_lost: bool = False) -> None: + super().__init__(message) + # instance-level override — на случай, если базовый класс поднимается + # напрямую с connection_lost=True без использования ConnectionLostError + if connection_lost: + self.connection_lost = True + + +class ConnectionLostError(FlashBackendError): + """Потеря USB-соединения посреди операции (обёртка над SPSDKConnectionError). + + Обёртка над spsdk.exceptions.SPSDKConnectionError, поднимается при + исчезновении устройства с шины во время выполнения SDP/McuBoot команд. + Отличается от DeviceNotFoundError, который возникает ДО начала операции + (устройство никогда не было подключено). + """ + + connection_lost: bool = True + + def __init__(self, message: str) -> None: + super().__init__(message, connection_lost=True) class DeviceNotFoundError(FlashBackendError): @@ -127,6 +175,21 @@ def _emit( progress_cb(FlashProgress(phase=phase, percent=percent, message=message)) +def _close_iface_quiet(iface: MbootUSBInterface) -> None: + """Закрыть интерфейс в finally, глотая любые ошибки. + + Внутри McuBoot() как context-manager закрытие уже происходит, поэтому + повторное close() на закрытом интерфейсе может выкинуть исключение + из libusbsio — нам это не важно, мы просто хотим гарантию, что если + McuBoot.__enter__ упал (окно между load_flashloader и with McuBoot()), + интерфейс не остался висеть с открытым HID-хэндлом. + """ + try: + iface.close() + except Exception: + pass + + # ─── Flashloader bring-up ─────────────────────────────────────────────────── @@ -155,6 +218,7 @@ def load_flashloader( :raises DeviceNotFoundError: SDP-устройство не найдено (и Flashloader тоже не поднят). :raises FlashLoaderTimeoutError: см. wait_for_flashloader(). + :raises ConnectionLostError: USB-соединение потеряно во время SDP-обмена. """ already = MbootUSBInterface.scan(device_id=_FLASHLOADER_DEVICE_ID) if already: @@ -176,9 +240,14 @@ def load_flashloader( f"Загрузка Flashloader через SDP ({_SDP_DEVICE_ID})", ) data = FLASHLOADER_BIN.read_bytes() - with SDP(sdp_devices[0]) as sdp: - sdp.write_file(FLASHLOADER_LOAD_ADDR, data) - sdp.jump_and_run(FLASHLOADER_LOAD_ADDR) + try: + 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: + raise ConnectionLostError( + f"USB-соединение потеряно при загрузке Flashloader: {exc}" + ) from exc iface = wait_for_flashloader() _emit(progress_cb, "load_flashloader", 100, "Flashloader готов") @@ -239,6 +308,7 @@ def flash( :param fcb_path: Явный FCB-блоб (custom-бинари). None → auto-config (write_fcb_auto, только для штатных firmware_test/production). :raises FlashBackendError: и подклассы — на любой ошибке. + :raises ConnectionLostError: обрыв USB посреди операции. """ if not hab_bin.exists(): raise FlashBackendError(f"Файл не найден: {hab_bin}") @@ -249,9 +319,14 @@ def flash( if not sdp_devices: raise DeviceNotFoundError(f"SDP-устройство не найдено ({_SDP_DEVICE_ID})") addr = FLASH_BASE + HAB_OFFSET - with SDP(sdp_devices[0]) as sdp: - sdp.write_file(addr, hab_bin.read_bytes()) - sdp.jump_and_run(addr) + try: + with SDP(sdp_devices[0]) as sdp: + sdp.write_file(addr, hab_bin.read_bytes()) + sdp.jump_and_run(addr) + except SPSDKConnectionError as exc: + raise ConnectionLostError( + f"USB-соединение потеряно при RAM-загрузке: {exc}" + ) from exc _emit(progress_cb, "done", 100, "Загружено в RAM") return @@ -261,59 +336,82 @@ def flash( hab_size = hab_bin.stat().st_size erase_size = ((HAB_OFFSET + hab_size + 0xFFF) // 0x1000) * 0x1000 - with McuBoot(iface) as mboot: - _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") - configure_flexspi(mboot) + # try/finally гарантирует закрытие iface даже если McuBoot.__enter__ падает + # ДО того, как SDP context-manager отработает (тонкое окно, но реальное: + # wait_for_flashloader вернул интерфейс, USB выдернут до McuBoot handshake). + try: + with McuBoot(iface) as mboot: + _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") + configure_flexspi(mboot) - _emit( - progress_cb, "erase", 0, f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт" - ) - ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) - if not ok: - raise FlashBackendError("flash_erase_region вернул False") + _emit( + progress_cb, + "erase", + 0, + f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт", + ) + ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) + if not ok: + raise FlashBackendError("flash_erase_region вернул False") - _emit(progress_cb, "fcb", 0, "Запись FCB") - if fcb_path is not None: - write_fcb_explicit(mboot, fcb_path) - else: - write_fcb_auto(mboot) + _emit(progress_cb, "fcb", 0, "Запись FCB") + if fcb_path is not None: + write_fcb_explicit(mboot, fcb_path) + else: + write_fcb_auto(mboot) - _emit(progress_cb, "write", 0, f"Запись {hab_bin.name} ({hab_size} байт)") + _emit(progress_cb, "write", 0, f"Запись {hab_bin.name} ({hab_size} байт)") - def _on_progress(current: int, total: int) -> None: - percent = int(current * 100 / total) if total else 0 - _emit(progress_cb, "write", percent, f"{current}/{total} байт") + def _on_progress(current: int, total: int) -> None: + percent = int(current * 100 / total) if total else 0 + _emit(progress_cb, "write", percent, f"{current}/{total} байт") - data = hab_bin.read_bytes() - ok = mboot.write_memory( - write_addr, data, mem_id=0, progress_callback=_on_progress - ) - if not ok: - raise FlashBackendError("write_memory (HAB-образ) вернул False") + data = hab_bin.read_bytes() + ok = mboot.write_memory( + write_addr, data, mem_id=0, progress_callback=_on_progress + ) + if not ok: + raise FlashBackendError("write_memory (HAB-образ) вернул False") - _emit(progress_cb, "reset", 0, "Reset") - mboot.reset(reopen=False) + _emit(progress_cb, "reset", 0, "Reset") + mboot.reset(reopen=False) + except SPSDKConnectionError as exc: + raise ConnectionLostError( + f"USB-соединение потеряно во время прошивки: {exc}" + ) from exc + finally: + _close_iface_quiet(iface) _emit(progress_cb, "done", 100, "Прошивка завершена успешно") def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: """Полная очистка Flash (см. flash_usb.py::erase_chip). После erase FCB - тоже стёрт — плата не загрузится до следующей прошивки.""" + тоже стёрт — плата не загрузится до следующей прошивки. + + :raises ConnectionLostError: обрыв USB посреди chip erase. + """ iface = load_flashloader(progress_cb) - with McuBoot(iface) as mboot: - _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") - configure_flexspi(mboot) + try: + with McuBoot(iface) as mboot: + _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") + configure_flexspi(mboot) - _emit(progress_cb, "erase", 0, "Полная очистка Flash (~30с)") - 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") + _emit(progress_cb, "erase", 0, "Полная очистка Flash (~30с)") + 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") - _emit(progress_cb, "reset", 0, "Reset") - mboot.reset(reopen=False) + _emit(progress_cb, "reset", 0, "Reset") + mboot.reset(reopen=False) + except SPSDKConnectionError as exc: + raise ConnectionLostError( + f"USB-соединение потеряно во время chip erase: {exc}" + ) from exc + finally: + _close_iface_quiet(iface) _emit(progress_cb, "done", 100, "Chip erase завершён") diff --git a/tools/production/app/flasher.py b/tools/production/app/flasher.py index 879d539..060cb50 100644 --- a/tools/production/app/flasher.py +++ b/tools/production/app/flasher.py @@ -48,16 +48,10 @@ logger = logging.getLogger(__name__) # Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug. _FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug") -# tools/host/build (или переопределено через BUILD_DIR) — та же логика, -# что раньше жила внутри flash_usb.py::main() и была ему видна только -# через CLI-аргументы --firmware/--build-type. Теперь резолвим сами. -_BUILD_DIR = Path(os.environ.get("BUILD_DIR", str(flash_backend.REPO_ROOT / "build"))) - def _firmware_hab_path(firmware: str) -> Path: - """Путь к готовому HAB-образу штатной прошивки (см. build.just — тот же - файл, что собирает `nxpimage hab export` при обычной сборке).""" - return _BUILD_DIR / _FIRMWARE_BUILD_TYPE / f"{firmware}_hab.bin" + """Делегирование в backend (Р6): dev/frozen резолв живёт там.""" + return flash_backend.firmware_hab_path(firmware, _FIRMWARE_BUILD_TYPE) def _resolve_custom_binaries_dir() -> Path: @@ -111,6 +105,20 @@ def _make_sync_progress_cb( return _sync_cb +def _format_error_message(exc: BaseException) -> str: + """Человекочитаемое сообщение для FlashProgress(phase="error"). + + Для ConnectionLostError (и любого FlashBackendError с connection_lost=True) + добавляет явный префикс — вариант А (согласовано): специального перехода + экрана нет, но в #flash-log причина должна читаться однозначно, без + необходимости лезть в общий лог-файл за трейсбеком. + """ + connection_lost = getattr(exc, "connection_lost", False) + if connection_lost: + return f"Соединение с платой потеряно: {exc}" + return str(exc) + + class Flasher: """ Async-обёртка над app/flash_backend.py. @@ -154,9 +162,17 @@ class Flasher: ) -> bool: """Выполнить flash_backend.flash()/erase_chip() в потоке. - flash_backend поднимает FlashBackendError вместо возврата False — - здесь это конвертируется обратно в контракт Flasher (bool + событие - phase="error"), который был у subprocess-версии (ненулевой returncode). + flash_backend поднимает FlashBackendError (включая ConnectionLostError, + см. Фазу 4) вместо возврата False — здесь это конвертируется обратно + в контракт Flasher (bool + событие phase="error"), который был у + subprocess-версии (ненулевой returncode). + + Отдельный except Exception — safety net (Фаза 4, согласовано): + любое непредвиденное исключение из worker-потока (не только + FlashBackendError) обязано вернуть управление в TUI с ok=False, + а не оставить кнопки заблокированными навсегда. KeyboardInterrupt/ + SystemExit/CancelledError не перехватываются — это BaseException, + не Exception, пробрасываются как есть """ try: await asyncio.to_thread(func, *args, progress_cb=sync_progress_cb, **kwargs) @@ -165,7 +181,22 @@ class Flasher: logger.error("%s: %s", getattr(func, "__name__", func), exc) if async_progress_cb is not None: await async_progress_cb( - FlashProgress(phase="error", percent=0, message=str(exc)) + FlashProgress( + phase="error", percent=0, message=_format_error_message(exc) + ) + ) + return False + except Exception as exc: # noqa: BLE001 — safety net, см. docstring + logger.exception( + "%s: непредвиденная ошибка", getattr(func, "__name__", func) + ) + if async_progress_cb is not None: + await async_progress_cb( + FlashProgress( + phase="error", + percent=0, + message=f"Непредвиденная ошибка: {exc}", + ) ) return False @@ -319,7 +350,18 @@ class Flasher: FlashProgress( phase="error", percent=0, - message=f"Ошибка сборки HAB-образа: {exc}", + message=f"Ошибка сборки HAB-образа: {_format_error_message(exc)}", + ) + ) + return None + except Exception as exc: # noqa: BLE001 — safety net, см. _run_flash_op + logger.exception("build_custom_hab: непредвиденная ошибка") + if progress_cb is not None: + await progress_cb( + FlashProgress( + phase="error", + percent=0, + message=f"Непредвиденная ошибка сборки HAB-образа: {exc}", ) ) return None diff --git a/tools/production/app/models.py b/tools/production/app/models.py index cdf4a60..48d0757 100644 --- a/tools/production/app/models.py +++ b/tools/production/app/models.py @@ -131,6 +131,7 @@ class ConfirmRequest: class FlashProgress: """Прогресс прошивки.""" - phase: str # "sdphost" | "blhost" | "done" | "error" + phase: str # "load_flashloader" | "configure" | "erase" | "fcb" | "write" + # | "reset" | "hab_build" | "done" | "error" percent: int # 0..100 message: str diff --git a/tools/production/app/screens/flash.py b/tools/production/app/screens/flash.py index 9ba872b..efa2270 100644 --- a/tools/production/app/screens/flash.py +++ b/tools/production/app/screens/flash.py @@ -49,7 +49,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin): Если плата физически отключена в простое — сессия считается недостоверной, экран сразу уходит на WaitingScreen (см. замечание №4 отчёта). Во время самой прошивки/erase мониторинг приостановлен — - обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess. + обрыв в этом случае обнаруживает сам flash_backend (SPSDKConnectionError + → ConnectionLostError, см. Фазу 4) и репортит через #flash-log. """ BINDINGS = [ @@ -154,6 +155,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin): def _check_sdp_present(self) -> bool: # Не считаем потерей соединения, если идёт активная операция — # flash_usb.py сам обработает реальный обрыв через subprocess. + # обрыв в этом случае обнаружит и обработает сам flash_backend + # (ConnectionLostError, см. Фазу 4), не watcher. if self._flashing: return True return Flasher.detect_sdp() diff --git a/tools/production/app/firmware_test_hab.bin b/tools/production/custom_binaries/firmware_test_hab.bin similarity index 100% rename from tools/production/app/firmware_test_hab.bin rename to tools/production/custom_binaries/firmware_test_hab.bin diff --git a/tools/production/spike/spike_readback.py b/tools/production/spike/spike_readback.py new file mode 100644 index 0000000..1c8133d --- /dev/null +++ b/tools/production/spike/spike_readback.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +spike_readback.py — диагностика Гейта 3: читает Flash обратно и сравнивает +с ожидаемыми файлами (FCB-блоб @0x60000000, HAB-образ @0x60001000). + +Запуск (плата в SDP или с уже поднятым Flashloader): + uv run python spike/spike_readback.py \ + --expect-fcb ../host/dcd/w25q128_fdcb.bin \ + --expect-image /путь/к/собранному.hab.bin +Дампы кладёт рядом: readback_fcb.bin, readback_image.bin. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app import flash_backend as fb # noqa: E402 +from spsdk.mboot import McuBoot # noqa: E402 + + +def _cmp(label: str, actual: bytes, expected: bytes) -> bool: + if actual == expected: + print(f" {label}: ✅ ПОБАЙТНО СОВПАДАЕТ ({len(actual)} байт)") + return True + n = min(len(actual), len(expected)) + diff_at = next((i for i in range(n) if actual[i] != expected[i]), n) + print( + f" {label}: ❌ РАСХОЖДЕНИЕ с офсета 0x{diff_at:X} " + f"(len actual={len(actual)}, expected={len(expected)})" + ) + print(f" actual [{diff_at:#x}]: {actual[diff_at : diff_at + 16].hex()}") + print(f" expected[{diff_at:#x}]: {expected[diff_at : diff_at + 16].hex()}") + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--expect-fcb", type=Path, required=True) + ap.add_argument("--expect-image", type=Path, required=True) + args = ap.parse_args() + + exp_fcb = args.expect_fcb.read_bytes() + exp_img = args.expect_image.read_bytes() + + iface = fb.load_flashloader(progress_cb=lambda p: print(f" {p.message}")) + ok = True + with McuBoot(iface) as mboot: + fb.configure_flexspi(mboot) + + fcb = mboot.read_memory(fb.FLASH_BASE, max(len(exp_fcb), 512), mem_id=0) + img = mboot.read_memory(fb.FLASH_BASE + fb.HAB_OFFSET, len(exp_img), mem_id=0) + if fcb is None or img is None: + print("❌ read_memory вернул None") + return 1 + + Path("readback_fcb.bin").write_bytes(fcb) + Path("readback_image.bin").write_bytes(img) + + ok = _cmp("FCB @0x60000000", fcb[: len(exp_fcb)], exp_fcb) and ok + ok = _cmp("Image @0x60001000", img, exp_img) and ok + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/production/tests/test_flash_backend.py b/tools/production/tests/test_flash_backend.py index 5021296..ef2677d 100644 --- a/tools/production/tests/test_flash_backend.py +++ b/tools/production/tests/test_flash_backend.py @@ -421,3 +421,28 @@ def test_build_custom_hab_bytes_match_golden(tmp_path): out1 = fb.build_custom_hab(raw, use_dcd=False) out2 = fb.build_custom_hab(raw, use_dcd=False) assert out1.read_bytes() == out2.read_bytes() + + +# ─── firmware_hab_path() — двухрежимный резолв (Р6) ────────────────────── + + +def test_firmware_hab_path_dev_default(monkeypatch): + monkeypatch.delenv("BUILD_DIR", raising=False) + p = fb.firmware_hab_path("firmware_test", "Debug") + assert p == fb.REPO_ROOT / "build" / "Debug" / "firmware_test_hab.bin" + + +def test_firmware_hab_path_dev_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("BUILD_DIR", str(tmp_path)) + p = fb.firmware_hab_path("bootloader", "Release") + assert p == tmp_path / "Release" / "bootloader_hab.bin" + + +def test_firmware_hab_path_frozen(monkeypatch, tmp_path): + exe = tmp_path / "dist" / "service_tui" + exe.parent.mkdir(parents=True) + exe.touch() + monkeypatch.setattr(fb.sys, "frozen", True, raising=False) + 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"