# Phase 4: connection loss handling with spsdk

This commit is contained in:
Dmitry Akimov 2026-07-03 17:19:16 +03:00
parent ad77c897e3
commit 234f1a60cf
8 changed files with 301 additions and 62 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -9,12 +9,18 @@ Zero Textual/asyncio импортов — модуль полностью син
без event loop (см. test_flash_backend.py). Async-обвязка (asyncio.to_thread без event loop (см. test_flash_backend.py). Async-обвязка (asyncio.to_thread
+ run_coroutine_threadsafe) забота Flasher (Фаза 2), не этого модуля. + run_coroutine_threadsafe) забота Flasher (Фаза 2), не этого модуля.
Открытые вопросы/допущения (см. сопроводительное сообщение в чате): Открытые вопросы/допущения:
- Одна сессия McuBoot на весь flash()/erase_chip(), а не переоткрытие - Одна сессия McuBoot на весь flash()/erase_chip(), а не переоткрытие
на каждую операцию, как в CLI flash_usb.py. на каждую операцию, как в CLI flash_usb.py.
- Имена FlashProgress.phase свои (не парсинг stdout blhost). - Имена 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 from __future__ import annotations
@ -22,11 +28,13 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
import os import os
import sys
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from spsdk.exceptions import SPSDKConnectionError
from spsdk.image.hab.hab_image import HabImage 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
@ -52,6 +60,15 @@ def fcb_blob_path(fcb_filename: str) -> Path:
return _HOST_DCD_DIR / fcb_filename 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 (те же переменные окружения, что уже приняты в проекте) ── # ─── USB VID:PID (те же переменные окружения, что уже приняты в проекте) ──
_BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16) _BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16)
@ -92,7 +109,38 @@ _HAB_OPTIONS_TEMPLATE = [
class FlashBackendError(Exception): 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): class DeviceNotFoundError(FlashBackendError):
@ -127,6 +175,21 @@ def _emit(
progress_cb(FlashProgress(phase=phase, percent=percent, message=message)) 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 ─────────────────────────────────────────────────── # ─── Flashloader bring-up ───────────────────────────────────────────────────
@ -155,6 +218,7 @@ def load_flashloader(
:raises DeviceNotFoundError: SDP-устройство не найдено (и Flashloader тоже не поднят). :raises DeviceNotFoundError: SDP-устройство не найдено (и Flashloader тоже не поднят).
:raises FlashLoaderTimeoutError: см. wait_for_flashloader(). :raises FlashLoaderTimeoutError: см. wait_for_flashloader().
:raises ConnectionLostError: USB-соединение потеряно во время SDP-обмена.
""" """
already = MbootUSBInterface.scan(device_id=_FLASHLOADER_DEVICE_ID) already = MbootUSBInterface.scan(device_id=_FLASHLOADER_DEVICE_ID)
if already: if already:
@ -176,9 +240,14 @@ def load_flashloader(
f"Загрузка Flashloader через SDP ({_SDP_DEVICE_ID})", f"Загрузка Flashloader через SDP ({_SDP_DEVICE_ID})",
) )
data = FLASHLOADER_BIN.read_bytes() data = FLASHLOADER_BIN.read_bytes()
try:
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:
raise ConnectionLostError(
f"USB-соединение потеряно при загрузке Flashloader: {exc}"
) from exc
iface = wait_for_flashloader() iface = wait_for_flashloader()
_emit(progress_cb, "load_flashloader", 100, "Flashloader готов") _emit(progress_cb, "load_flashloader", 100, "Flashloader готов")
@ -239,6 +308,7 @@ def flash(
:param fcb_path: Явный FCB-блоб (custom-бинари). None auto-config :param fcb_path: Явный FCB-блоб (custom-бинари). None auto-config
(write_fcb_auto, только для штатных firmware_test/production). (write_fcb_auto, только для штатных firmware_test/production).
:raises FlashBackendError: и подклассы на любой ошибке. :raises FlashBackendError: и подклассы на любой ошибке.
:raises ConnectionLostError: обрыв USB посреди операции.
""" """
if not hab_bin.exists(): if not hab_bin.exists():
raise FlashBackendError(f"Файл не найден: {hab_bin}") raise FlashBackendError(f"Файл не найден: {hab_bin}")
@ -249,9 +319,14 @@ def flash(
if not sdp_devices: if not sdp_devices:
raise DeviceNotFoundError(f"SDP-устройство не найдено ({_SDP_DEVICE_ID})") raise DeviceNotFoundError(f"SDP-устройство не найдено ({_SDP_DEVICE_ID})")
addr = FLASH_BASE + HAB_OFFSET addr = FLASH_BASE + HAB_OFFSET
try:
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:
raise ConnectionLostError(
f"USB-соединение потеряно при RAM-загрузке: {exc}"
) from exc
_emit(progress_cb, "done", 100, "Загружено в RAM") _emit(progress_cb, "done", 100, "Загружено в RAM")
return return
@ -261,12 +336,19 @@ def flash(
hab_size = hab_bin.stat().st_size hab_size = hab_bin.stat().st_size
erase_size = ((HAB_OFFSET + hab_size + 0xFFF) // 0x1000) * 0x1000 erase_size = ((HAB_OFFSET + hab_size + 0xFFF) // 0x1000) * 0x1000
# try/finally гарантирует закрытие iface даже если McuBoot.__enter__ падает
# ДО того, как SDP context-manager отработает (тонкое окно, но реальное:
# wait_for_flashloader вернул интерфейс, USB выдернут до McuBoot handshake).
try:
with McuBoot(iface) as mboot: with McuBoot(iface) as mboot:
_emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR")
configure_flexspi(mboot) configure_flexspi(mboot)
_emit( _emit(
progress_cb, "erase", 0, f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт" progress_cb,
"erase",
0,
f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт",
) )
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:
@ -293,15 +375,25 @@ def flash(
_emit(progress_cb, "reset", 0, "Reset") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) 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, "Прошивка завершена успешно") _emit(progress_cb, "done", 100, "Прошивка завершена успешно")
def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
"""Полная очистка Flash (см. flash_usb.py::erase_chip). После erase FCB """Полная очистка Flash (см. flash_usb.py::erase_chip). После erase FCB
тоже стёрт плата не загрузится до следующей прошивки.""" тоже стёрт плата не загрузится до следующей прошивки.
:raises ConnectionLostError: обрыв USB посреди chip erase.
"""
iface = load_flashloader(progress_cb) iface = load_flashloader(progress_cb)
try:
with McuBoot(iface) as mboot: with McuBoot(iface) as mboot:
_emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR")
configure_flexspi(mboot) configure_flexspi(mboot)
@ -314,6 +406,12 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
_emit(progress_cb, "reset", 0, "Reset") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) 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 завершён") _emit(progress_cb, "done", 100, "Chip erase завершён")

View file

@ -48,16 +48,10 @@ logger = logging.getLogger(__name__)
# Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug. # Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug.
_FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "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: def _firmware_hab_path(firmware: str) -> Path:
"""Путь к готовому HAB-образу штатной прошивки (см. build.just — тот же """Делегирование в backend (Р6): dev/frozen резолв живёт там."""
файл, что собирает `nxpimage hab export` при обычной сборке).""" return flash_backend.firmware_hab_path(firmware, _FIRMWARE_BUILD_TYPE)
return _BUILD_DIR / _FIRMWARE_BUILD_TYPE / f"{firmware}_hab.bin"
def _resolve_custom_binaries_dir() -> Path: def _resolve_custom_binaries_dir() -> Path:
@ -111,6 +105,20 @@ def _make_sync_progress_cb(
return _sync_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: class Flasher:
""" """
Async-обёртка над app/flash_backend.py. Async-обёртка над app/flash_backend.py.
@ -154,9 +162,17 @@ class Flasher:
) -> bool: ) -> bool:
"""Выполнить flash_backend.flash()/erase_chip() в потоке. """Выполнить flash_backend.flash()/erase_chip() в потоке.
flash_backend поднимает FlashBackendError вместо возврата False flash_backend поднимает FlashBackendError (включая ConnectionLostError,
здесь это конвертируется обратно в контракт Flasher (bool + событие см. Фазу 4) вместо возврата False здесь это конвертируется обратно
phase="error"), который был у subprocess-версии (ненулевой returncode). в контракт Flasher (bool + событие phase="error"), который был у
subprocess-версии (ненулевой returncode).
Отдельный except Exception safety net (Фаза 4, согласовано):
любое непредвиденное исключение из worker-потока (не только
FlashBackendError) обязано вернуть управление в TUI с ok=False,
а не оставить кнопки заблокированными навсегда. KeyboardInterrupt/
SystemExit/CancelledError не перехватываются это BaseException,
не Exception, пробрасываются как есть
""" """
try: try:
await asyncio.to_thread(func, *args, progress_cb=sync_progress_cb, **kwargs) 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) logger.error("%s: %s", getattr(func, "__name__", func), exc)
if async_progress_cb is not None: if async_progress_cb is not None:
await async_progress_cb( 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 return False
@ -319,7 +350,18 @@ class Flasher:
FlashProgress( FlashProgress(
phase="error", phase="error",
percent=0, 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 return None

View file

@ -131,6 +131,7 @@ class ConfirmRequest:
class FlashProgress: 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 percent: int # 0..100
message: str message: str

View file

@ -49,7 +49,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
Если плата физически отключена в простое сессия считается Если плата физически отключена в простое сессия считается
недостоверной, экран сразу уходит на WaitingScreen (см. замечание недостоверной, экран сразу уходит на WaitingScreen (см. замечание
4 отчёта). Во время самой прошивки/erase мониторинг приостановлен 4 отчёта). Во время самой прошивки/erase мониторинг приостановлен
обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess. обрыв в этом случае обнаруживает сам flash_backend (SPSDKConnectionError
ConnectionLostError, см. Фазу 4) и репортит через #flash-log.
""" """
BINDINGS = [ BINDINGS = [
@ -154,6 +155,8 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
def _check_sdp_present(self) -> bool: def _check_sdp_present(self) -> bool:
# Не считаем потерей соединения, если идёт активная операция — # Не считаем потерей соединения, если идёт активная операция —
# flash_usb.py сам обработает реальный обрыв через subprocess. # flash_usb.py сам обработает реальный обрыв через subprocess.
# обрыв в этом случае обнаружит и обработает сам flash_backend
# (ConnectionLostError, см. Фазу 4), не watcher.
if self._flashing: if self._flashing:
return True return True
return Flasher.detect_sdp() return Flasher.detect_sdp()

View file

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

View file

@ -421,3 +421,28 @@ def test_build_custom_hab_bytes_match_golden(tmp_path):
out1 = fb.build_custom_hab(raw, use_dcd=False) out1 = fb.build_custom_hab(raw, use_dcd=False)
out2 = fb.build_custom_hab(raw, use_dcd=False) out2 = fb.build_custom_hab(raw, use_dcd=False)
assert out1.read_bytes() == out2.read_bytes() 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"