# flasher.py - tested on MacOS

This commit is contained in:
Dmitry Akimov 2026-07-02 18:41:23 +03:00
parent b55ec8532c
commit ad77c897e3

View file

@ -1,10 +1,26 @@
""" """
flasher.py обёртка над tools/host/flash_usb.py для TUI. flasher.py async-обёртка над app/flash_backend.py для TUI.
Запускает flash_usb.py как subprocess, парсит stdout для прогресса, Фаза 2: внутренности переведены с subprocess (flash_usb.py) на прямые
пробрасывает события через asyncio.Queue в TUI. вызовы синхронного app/flash_backend.py через asyncio.to_thread. Публичный
API класса Flasher не менялся с Фазы 0 сигнатуры те же, что в исходной
subprocess-версии.
Не дублирует spsdk-окружение переиспользует tools/host/ uv-проект. Мост sync async для прогресса: flash_backend отдаёт события через
синхронный callback (вызывается из worker-потока asyncio.to_thread), здесь
он оборачивается в asyncio.run_coroutine_threadsafe(...).result()
согласовано с автором проекта (Фаза 2, вопрос 1): блокирующий .result()
внутри worker-потока гарантирует, что события прогресса приходят в TUI
строго по порядку.
Отмена прошивки посреди операции НЕ поддерживается и не должна
поддерживаться (согласовано, Фаза 2, вопрос 2) если стирание/запись уже
начались, они обязаны докрутиться до конца (успешно или с ошибкой).
Специального кода для этого не потребовалось: Python-поток, запущенный
через asyncio.to_thread, нельзя прервать снаружи если ожидающая
корутина в TUI получит CancelledError, сам поток всё равно продолжит
работу в фоне до естественного завершения flash_backend.flash()/
erase_chip(). Это и есть требуемое поведение, а не обходной путь.
Публичный API: Публичный API:
Flasher.flash(target, bin_path, progress_cb) async, прогресс через callback Flasher.flash(target, bin_path, progress_cb) async, прогресс через callback
@ -18,31 +34,30 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import re import shutil
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
from typing import Awaitable, Callable, Optional from typing import Awaitable, Callable, Optional
from . import flash_backend
from .models import FcbVariant, FlashProgress, FlashTarget from .models import FcbVariant, FlashProgress, FlashTarget
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# VID/PID констант — читаются из env, fallback на известные значения
_BOOTROM_VID = int(os.environ.get("BOOTROM_VID", "0x1fc9"), 16)
_BOOTROM_PID = int(os.environ.get("BOOTROM_PID", "0x0130"), 16)
_CDC_VID = int(os.environ.get("SERVICE_CDC_VID", "0x1996"), 16)
_CDC_PID = int(os.environ.get("SERVICE_CDC_PID", "0x00ad"), 16)
# Тип сборки firmware_test для прошивки (Debug | Release). # Тип сборки firmware_test для прошивки (Debug | Release).
# Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug. # Release временно нестабилен (см. отчёт о тестировании) — по умолчанию Debug.
_FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug") _FIRMWARE_BUILD_TYPE = os.environ.get("FIRMWARE_BUILD_TYPE", "Debug")
# Путь до flash_usb.py относительно корня репозитория # tools/host/build (или переопределено через BUILD_DIR) — та же логика,
_FLASH_USB_SCRIPT = Path(__file__).parents[3] / "tools" / "host" / "flash_usb.py" # что раньше жила внутри flash_usb.py::main() и была ему видна только
_HOST_TOOLS_DIR = _FLASH_USB_SCRIPT.parent # через CLI-аргументы --firmware/--build-type. Теперь резолвим сами.
_HAB_DIR = _HOST_TOOLS_DIR / "hab" _BUILD_DIR = Path(os.environ.get("BUILD_DIR", str(flash_backend.REPO_ROOT / "build")))
_DCD_DIR = _HOST_TOOLS_DIR / "dcd"
def _firmware_hab_path(firmware: str) -> Path:
"""Путь к готовому HAB-образу штатной прошивки (см. build.just — тот же
файл, что собирает `nxpimage hab export` при обычной сборке)."""
return _BUILD_DIR / _FIRMWARE_BUILD_TYPE / f"{firmware}_hab.bin"
def _resolve_custom_binaries_dir() -> Path: def _resolve_custom_binaries_dir() -> Path:
@ -67,103 +82,114 @@ def _resolve_custom_binaries_dir() -> Path:
CUSTOM_BINARIES_DIR = _resolve_custom_binaries_dir() CUSTOM_BINARIES_DIR = _resolve_custom_binaries_dir()
# Паттерны stdout flash_usb.py для извлечения прогресса
_RE_PERCENT = re.compile(r"(\d{1,3})\s*%")
_RE_PHASE = re.compile(r"(sdphost|blhost|Writing|Erasing|Verifying)", re.IGNORECASE)
ProgressCallback = Callable[[FlashProgress], Awaitable[None]] ProgressCallback = Callable[[FlashProgress], Awaitable[None]]
def _detect_usb(vid: int, pid: int) -> bool: def _make_sync_progress_cb(
progress_cb: Optional[ProgressCallback],
loop: asyncio.AbstractEventLoop,
) -> Optional[flash_backend.ProgressCallback]:
"""Мост sync (flash_backend, вызывается из worker-потока) → async (TUI).
.result() намеренно блокирующий вызов внутри worker-потока: гарантирует
доставку событий прогресса в порядке их возникновения (см. docstring
модуля). Исключения из progress_cb (например, если экран уже закрыт)
логируются и не прерывают саму операцию прошивки см. договорённость
по отмене (Фаза 2, вопрос 2): прошивка должна докрутиться до конца
независимо от состояния UI.
""" """
Проверить наличие USB-устройства по VID/PID (синхронно). if progress_cb is None:
return None
Два метода детекта: def _sync_cb(progress: FlashProgress) -> None:
1. pyusb (usb.core) видит все USB-устройства включая SDP bulk/HID. future = asyncio.run_coroutine_threadsafe(progress_cb(progress), loop)
На macOS SDP-устройство (1FC9:0130) не создаёт serial-порт
и невидимо через serial.tools.list_ports.
2. serial.tools.list_ports fallback для CDC ACM устройств.
"""
# Метод 1: pyusb — работает для SDP и CDC
try: try:
import usb.core future.result()
dev = usb.core.find(idVendor=vid, idProduct=pid)
if dev is not None:
return True
except Exception: except Exception:
pass logger.exception("progress_cb выбросил исключение из TUI-потока")
# Метод 2: serial list_ports — fallback для CDC ACM return _sync_cb
try:
import serial.tools.list_ports
for info in serial.tools.list_ports.comports():
if info.vid == vid and info.pid == pid:
return True
except Exception:
pass
return False
class Flasher: class Flasher:
""" """
Async-обёртка над flash_usb.py. Async-обёртка над app/flash_backend.py.
Пример:: Пример::
flasher = Flasher() flasher = Flasher()
await flasher.flash(
target=FlashTarget.FIRMWARE_TEST, async def on_progress(p: FlashProgress) -> None:
progress_cb=lambda p: print(p.message), print(p.message)
)
await flasher.flash(target=FlashTarget.FIRMWARE_TEST, progress_cb=on_progress)
""" """
def __init__(self) -> None: # ── Detection (делегируется в flash_backend, см. Р7) ──────────────────
self._proc: Optional[asyncio.subprocess.Process] = None
# ── Detection ───────────────────────────────────────────────────────────
@staticmethod @staticmethod
def detect_sdp() -> bool: def detect_sdp() -> bool:
"""True если виден BootROM SDP (1FC9:0130).""" """True если виден BootROM SDP."""
return _detect_usb(_BOOTROM_VID, _BOOTROM_PID) return flash_backend.detect_sdp()
@staticmethod @staticmethod
def detect_cdc() -> bool: def detect_cdc() -> bool:
"""True если виден CDC firmware_test (1996:00AD).""" """True если виден CDC firmware_test."""
return _detect_usb(_CDC_VID, _CDC_PID) return flash_backend.detect_cdc()
@staticmethod @staticmethod
def list_custom_binaries() -> list[Path]: def list_custom_binaries() -> list[Path]:
"""Отсканировать custom_binaries/ на *.bin, отсортировано по имени.""" """Отсканировать custom_binaries/ на *.bin, отсортировано по имени."""
return sorted(CUSTOM_BINARIES_DIR.glob("*.bin")) return sorted(CUSTOM_BINARIES_DIR.glob("*.bin"))
# ── Erase ──────────────────────────────────────────────────────────────── # ── Общий раннер: flash_backend-операция в потоке + перевод ошибок ────
async def _run_flash_op(
self,
func: Callable[..., None],
*args: object,
async_progress_cb: Optional[ProgressCallback],
sync_progress_cb: Optional[flash_backend.ProgressCallback],
**kwargs: object,
) -> bool:
"""Выполнить flash_backend.flash()/erase_chip() в потоке.
flash_backend поднимает FlashBackendError вместо возврата False
здесь это конвертируется обратно в контракт Flasher (bool + событие
phase="error"), который был у subprocess-версии (ненулевой returncode).
"""
try:
await asyncio.to_thread(func, *args, progress_cb=sync_progress_cb, **kwargs)
return True
except flash_backend.FlashBackendError as exc:
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))
)
return False
# ── Erase ────────────────────────────────────────────────────────────
async def erase_chip( async def erase_chip(
self, self,
progress_cb: Optional[ProgressCallback] = None, progress_cb: Optional[ProgressCallback] = None,
) -> bool: ) -> bool:
""" """
Chip erase Flash через USB SDP (flash_usb.py --erase-chip). Chip erase Flash через USB SDP (flash_backend.erase_chip).
Занимает ~30 с для W25Q128. FCB будет стёрт. Занимает ~30 с для W25Q128. FCB будет стёрт.
:return: True при успехе. :return: True при успехе.
""" """
cmd = [ loop = asyncio.get_running_loop()
"uv", sync_cb = _make_sync_progress_cb(progress_cb, loop)
"run", return await self._run_flash_op(
"--directory", flash_backend.erase_chip,
str(_HOST_TOOLS_DIR), async_progress_cb=progress_cb,
"python", sync_progress_cb=sync_cb,
str(_FLASH_USB_SCRIPT), )
"--erase-chip",
]
return await self._run_cmd(cmd, "erase", progress_cb)
# ── Flash ──────────────────────────────────────────────────────────────── # ── Flash ────────────────────────────────────────────────────────────
async def flash( async def flash(
self, self,
@ -174,257 +200,126 @@ class Flasher:
fcb_variant: FcbVariant = FcbVariant.W25Q128, fcb_variant: FcbVariant = FcbVariant.W25Q128,
) -> bool: ) -> bool:
""" """
Запустить прошивку через flash_usb.py. Запустить прошивку через flash_backend.
:param target: Что прошиваем (firmware_test, production или custom). :param target: Что прошиваем (firmware_test, production или custom).
:param progress_cb: Async callback с FlashProgress (может быть None). :param progress_cb: Async callback с FlashProgress (может быть None).
:param bin_path: Путь к бинарю (обязателен для CUSTOM). :param bin_path: Путь к бинарю (обязателен для CUSTOM; для
FIRMWARE_TEST опционален, уже готовый HAB-образ
вместо штатной сборки из BUILD_DIR).
:param use_dcd: Только для CUSTOM включить DCDFilePath (SDRAM-init) :param use_dcd: Только для CUSTOM включить DCDFilePath (SDRAM-init)
при сборке HAB-образа через nxpimage. при сборке HAB-образа через HabImage.
:param fcb_variant: Только для CUSTOM какой явный FCB-блоб (dcd/*_fdcb.bin) :param fcb_variant: Только для CUSTOM какой явный FCB-блоб (dcd/*_fdcb.bin)
записать в Flash[0x60000000] вместо auto-config. записать в Flash[0x60000000] вместо auto-config.
:return: True при успехе. :return: True при успехе.
""" """
loop = asyncio.get_running_loop()
sync_cb = _make_sync_progress_cb(progress_cb, loop)
if target == FlashTarget.FIRMWARE_TEST: if target == FlashTarget.FIRMWARE_TEST:
if bin_path is not None: hab_bin = (
return await self._run_flash_bin(bin_path, progress_cb) bin_path.resolve()
return await self._run_flash("firmware_test", progress_cb) if bin_path is not None
else _firmware_hab_path("firmware_test")
)
return await self._run_flash_op(
flash_backend.flash,
hab_bin,
async_progress_cb=progress_cb,
sync_progress_cb=sync_cb,
)
elif target == FlashTarget.PRODUCTION: elif target == FlashTarget.PRODUCTION:
ok = await self._run_flash("bootloader", progress_cb) ok = await self._run_flash_op(
flash_backend.flash,
_firmware_hab_path("bootloader"),
async_progress_cb=progress_cb,
sync_progress_cb=sync_cb,
)
if ok: if ok:
ok = await self._run_flash("app", progress_cb) ok = await self._run_flash_op(
flash_backend.flash,
_firmware_hab_path("app"),
async_progress_cb=progress_cb,
sync_progress_cb=sync_cb,
)
return ok return ok
elif target == FlashTarget.CUSTOM: elif target == FlashTarget.CUSTOM:
if bin_path is None: if bin_path is None:
raise ValueError("FlashTarget.CUSTOM требует bin_path") raise ValueError("FlashTarget.CUSTOM требует bin_path")
return await self._run_flash_custom( return await self._flash_custom(
bin_path, use_dcd, fcb_variant, progress_cb bin_path, use_dcd, fcb_variant, progress_cb, sync_cb
) )
return False return False
async def _run_flash_custom( async def _flash_custom(
self, self,
raw_bin_path: Path, raw_bin_path: Path,
use_dcd: bool, use_dcd: bool,
fcb_variant: FcbVariant, fcb_variant: FcbVariant,
progress_cb: Optional[ProgressCallback], progress_cb: Optional[ProgressCallback],
sync_cb: Optional[flash_backend.ProgressCallback],
) -> bool: ) -> bool:
""" """
Прошить «сырой» (не-HAB) кастомный бинарник из custom_binaries/. Прошить «сырой» (не-HAB) кастомный бинарник из custom_binaries/.
Два шага: Два шага:
1. Собрать HAB-образ (IVT + опционально DCD, БЕЗ FCB) через 1. Собрать HAB-образ (IVT + опционально DCD, БЕЗ FCB) через
nxpimage так же, как just build::hab-* собирает штатные flash_backend.build_custom_hab() (HabImage, см. Фазу 0) сам
прошивки, только конфиг генерируется на лету под выбранный файл. эмитит phase="hab_build" 0%/100% через sync_cb, отдельно
2. Прошить получившийся HAB-образ через flash_usb.py --bin-path, дублировать здесь не нужно.
подставив явный FCB-блоб (--fcb-path) под выбранный тип памяти 2. Прошить получившийся HAB-образ через flash_backend.flash(),
см. FcbVariant. подставив явный FCB-блоб под выбранный тип памяти (FcbVariant).
"""
if progress_cb is not None:
await progress_cb(
FlashProgress(
phase="nxpimage", percent=0, message="Сборка HAB-образа..."
)
)
hab_bin = await self._build_custom_hab(raw_bin_path, use_dcd, progress_cb) Временная директория build_custom_hab() (tempfile.mkdtemp) целиком
удаляется в finally не только *.bin, как было в subprocess-версии.
"""
hab_bin = await self._build_custom_hab(
raw_bin_path, use_dcd, progress_cb, sync_cb
)
if hab_bin is None: if hab_bin is None:
return False
fcb_path = flash_backend.fcb_blob_path(fcb_variant.fcb_filename)
try:
return await self._run_flash_op(
flash_backend.flash,
hab_bin,
fcb_path=fcb_path,
async_progress_cb=progress_cb,
sync_progress_cb=sync_cb,
)
finally:
shutil.rmtree(hab_bin.parent, ignore_errors=True)
async def _build_custom_hab(
self,
raw_bin_path: Path,
use_dcd: bool,
progress_cb: Optional[ProgressCallback],
sync_cb: Optional[flash_backend.ProgressCallback],
) -> Optional[Path]:
"""Обёртка над flash_backend.build_custom_hab() в потоке.
:return: путь к собранному *.hab.bin, либо None при ошибке.
"""
try:
return await asyncio.to_thread(
flash_backend.build_custom_hab,
raw_bin_path,
use_dcd,
progress_cb=sync_cb,
)
except flash_backend.FlashBackendError as exc:
logger.error("build_custom_hab: %s", exc)
if progress_cb is not None: if progress_cb is not None:
await progress_cb( await progress_cb(
FlashProgress( FlashProgress(
phase="error", phase="error",
percent=0, percent=0,
message="Ошибка сборки HAB-образа (nxpimage)", message=f"Ошибка сборки HAB-образа: {exc}",
) )
) )
return False
fcb_path = _DCD_DIR / fcb_variant.fcb_filename
try:
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--bin-path",
str(hab_bin),
"--fcb-path",
str(fcb_path),
]
return await self._run_cmd(cmd, raw_bin_path.stem, progress_cb)
finally:
hab_bin.unlink(missing_ok=True)
async def _build_custom_hab(
self,
raw_bin: Path,
use_dcd: bool,
progress_cb: Optional[ProgressCallback] = None,
) -> Optional[Path]:
"""
Собрать HAB-образ из сырого бинарника через nxpimage.
Временный YAML пишется прямо в tools/host/hab/ (как и штатные
hab_*.yaml) и nxpimage запускается с cwd=tools/host/hab/ это
обязательно: относительный DCDFilePath ("../dcd/dcd.bin") в
существующих конфигах резолвится именно так (см. build.just,
группа hab_image_gen `cd tools/host/hab && uv run nxpimage ...`).
Отходить от этой схемы рискованно nxpimage не документирует
явно, от чего резолвит относительные пути.
:return: путь к собранному *.hab.bin, либо None при ошибке nxpimage.
"""
yaml_f = tempfile.NamedTemporaryFile(
dir=_HAB_DIR, suffix=".yaml", prefix="_tui_custom_", delete=False
)
yaml_path = Path(yaml_f.name)
out_path = yaml_path.with_suffix(".hab.bin")
lines = [
"options:",
" flags: 0x00",
" startAddress: 0x60000000",
" ivtOffset: 0x1000",
" initialLoadSize: 0x2000",
" family: mimxrt1050",
]
if use_dcd:
lines.append(" DCDFilePath: ../dcd/dcd.bin")
lines.append(f'inputImageFile: "{raw_bin.resolve()}"')
lines.append("sections: []")
try:
yaml_f.write("\n".join(lines).encode("utf-8"))
yaml_f.close()
cmd = [
"uv",
"run",
"nxpimage",
"hab",
"export",
"--force",
"-c",
str(yaml_path),
"-o",
str(out_path),
]
logger.info("Building custom HAB image: %s", " ".join(cmd))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(_HAB_DIR),
)
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.decode("utf-8", errors="replace").rstrip()
logger.debug("nxpimage: %s", line)
if progress_cb is not None and line:
await progress_cb(
FlashProgress(phase="nxpimage", percent=0, message=line)
)
await proc.wait()
if proc.returncode != 0 or not out_path.exists():
logger.error("nxpimage hab export failed (rc=%s)", proc.returncode)
return None return None
return out_path
finally:
yaml_path.unlink(missing_ok=True)
async def _run_flash(
self,
firmware: str,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Запустить flash_usb.py для одного бинаря (стандартный firmware)."""
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--firmware",
firmware,
"--build-type",
_FIRMWARE_BUILD_TYPE,
]
return await self._run_cmd(cmd, firmware, progress_cb)
async def _run_flash_bin(
self,
bin_path: Path,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Запустить flash_usb.py для произвольного бинаря."""
cmd = [
"uv",
"run",
"--directory",
str(_HOST_TOOLS_DIR),
"python",
str(_FLASH_USB_SCRIPT),
"--bin-path",
str(bin_path.resolve()),
]
return await self._run_cmd(cmd, bin_path.stem, progress_cb)
async def _run_cmd(
self,
cmd: list[str],
label: str,
progress_cb: Optional[ProgressCallback],
) -> bool:
"""Общий subprocess runner для всех операций flash_usb.py."""
logger.info("Running: %s", " ".join(cmd))
try:
self._proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(_HOST_TOOLS_DIR),
)
assert self._proc.stdout is not None
async for raw_line in self._proc.stdout:
line = raw_line.decode("utf-8", errors="replace").rstrip()
logger.debug("flash_usb [%s]: %s", label, line)
if progress_cb is not None:
progress = _parse_progress(line)
if progress is not None:
await progress_cb(progress)
await self._proc.wait()
success = self._proc.returncode == 0
if progress_cb is not None:
phase = "done" if success else "error"
msg = "Завершено" if success else "Ошибка"
await progress_cb(FlashProgress(phase=phase, percent=100, message=msg))
return success
except Exception as exc:
logger.error("Subprocess error [%s]: %s", label, exc)
if progress_cb is not None:
await progress_cb(
FlashProgress(phase="error", percent=0, message=str(exc))
)
return False
finally:
self._proc = None
def _parse_progress(line: str) -> Optional[FlashProgress]:
"""
Извлечь прогресс из строки stdout flash_usb.py.
Возвращает None если строка не несёт прогресс-информации.
"""
percent_m = _RE_PERCENT.search(line)
phase_m = _RE_PHASE.search(line)
if percent_m is None and phase_m is None:
return None
percent = int(percent_m.group(1)) if percent_m else 0
phase = phase_m.group(1).lower() if phase_m else "flash"
return FlashProgress(phase=phase, percent=percent, message=line.strip())