From 1d9055628aa624f69ad38150a72c682cf044aaf8 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Mon, 13 Jul 2026 13:45:05 +0300 Subject: [PATCH 1/2] # service_tui: fix 1 force written QE=1 bit FlexSPI W25Q --- docs/HOW_TO_FLASH.md | 2 +- docs/mimxrt1052/HAB_GUIDE.md | 2 +- tools/host/flash_usb.py | 14 +- tools/service_tui/app/flash_backend.py | 262 ++++++++++++++++-- tools/service_tui/docs/DEV_ARCH.md | 108 +++++++- tools/service_tui/tests/test_flash_backend.py | 182 ++++++++++-- 6 files changed, 501 insertions(+), 69 deletions(-) diff --git a/docs/HOW_TO_FLASH.md b/docs/HOW_TO_FLASH.md index ff64ddc..d43b790 100644 --- a/docs/HOW_TO_FLASH.md +++ b/docs/HOW_TO_FLASH.md @@ -64,7 +64,7 @@ just host::flash-production # bootloader release + app release (с подт flowchart TD A["Плата в SDP-режиме\n1FC9:0130"] --> B["sdphost\nзагрузить ivt_flashloader.bin\nв RAM 0x20001C00"] B --> C["sdphost jump-address\nFlashloader поднимается\nкак 15A2:0073"] - C --> D["configure-memory 0xC0000007\nинициализация FlexSPI NOR"] + C --> D["configure-memory 0xC0000207\nинициализация FlexSPI NOR + QE-бит"] D --> E["flash-erase-region 0x60000000"] E --> F["configure-memory 0xF000000F\nзапись FCB в 0x60000000"] F --> G["write-memory 0x60001000\nHAB-образ"] diff --git a/docs/mimxrt1052/HAB_GUIDE.md b/docs/mimxrt1052/HAB_GUIDE.md index 291aadb..fcb0702 100644 --- a/docs/mimxrt1052/HAB_GUIDE.md +++ b/docs/mimxrt1052/HAB_GUIDE.md @@ -181,7 +181,7 @@ BootROM в режиме SDP умеет только писать в RAM и пр │ Flashloader запущен │ blhost -u 0x15A2,0x0073 - ├─ fill-memory 0x2000 4 0xC0000007 ← option word для FlexSPI NOR + ├─ fill-memory 0x2000 4 0xC0000207 ← option word для FlexSPI NOR (+QE-бит) ├─ configure-memory 9 0x2000 ← Flashloader пишет FCB в Flash ├─ flash-erase-region 0x60000000 … ├─ write-memory 0x60002000 firmware_hab.bin ← HAB образ diff --git a/tools/host/flash_usb.py b/tools/host/flash_usb.py index cd02696..de14924 100644 --- a/tools/host/flash_usb.py +++ b/tools/host/flash_usb.py @@ -62,11 +62,19 @@ SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130") BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073") # ─── Аппаратные константы ───────────────────────────────────────────────────── -# FlexSPI NOR config option word: 0xC0000007 +# FlexSPI NOR config option word: 0xC0000207 # bits[31:28]=0xC — tag (QuadSPI NOR) -# bits[3:0]=0x7 — option size +# bits[11:8]=0x2 — quad_mode_setting: установить QE-бит (Status Register 2 +# bit 1, формат Winbond W25Q). КРИТИЧНО: часть партий +# W25Q128 приходит с завода с QE=0 — без этого поля (было +# 0xC0000007) любая flash-операция на них падает с +# FlexSPINOR Command Failure. QE энергонезависимый; для +# чипов с уже установленным QE — no-op. Значение сверено +# с NXP MCUBootUtility (память W25Q). См. service_tui +# flash_backend.py, docstring Р15. +# bits[3:0]=0x7 — maxFreq FLEXSPI_OPTION_ADDR = "0x2000" -FLEXSPI_OPTION_VALUE = "0xC0000007" +FLEXSPI_OPTION_VALUE = "0xC0000207" FLEXSPI_MEMORY_ID = "9" # FlexSPI NOR memory interface ID # Option word для записи FCB: tag=0xF → Write FCB command diff --git a/tools/service_tui/app/flash_backend.py b/tools/service_tui/app/flash_backend.py index 826c9c0..a908036 100644 --- a/tools/service_tui/app/flash_backend.py +++ b/tools/service_tui/app/flash_backend.py @@ -21,6 +21,110 @@ Zero Textual/asyncio импортов — модуль полностью син закрывается в finally на любом исходе (защита от утечки HID-хэндла в редком окне «wait_for_flashloader вернул интерфейс → USB выдернут → McuBoot.__enter__ упал»). + +Р13 (полевой баг, ~50/500 плат — flash_erase_region/flash_erase_all +возвращали False с «устройство пропало с шины», хотя плата оставалась на +месте и стиралась штатно через NXP MCUBootUtility): два независимых бага. +(1) _fail_command() проверял присутствие BootROM SDP, а к моменту вызова +плата уже спрыгнула на Flashloader (см. load_flashloader()) — проверка +гарантированно возвращала «нет» независимо от реального состояния платы, +любой False от Flashloader-команды маскировался под обрыв USB. (2) +flash()/flash_erase_region и configure_flexspi() (в обоих путях) работали +на spsdk-дефолтном HID read-таймауте 2000мс (UsbDevice.__init__) — erase_chip() +поднимал таймаут только перед flash_erase_all, уже ПОСЛЕ configure_flexspi(). +McuBoot(iface) создаётся с cmd_exception=False (дефолт) — таймаут не бросает +исключение, а тихо оседает в status_code=NO_RESPONSE, поэтому команда просто +возвращает False без трассировки. На части плат (полевые логи: ~2с до отказа +на flash_erase_region) реальный ответ не укладывался в 2с. Фикс: единый +MCUBOOT_CMD_TIMEOUT_MS выставляется сразу после load_flashloader(), до первой +команды сессии; _fail_command() проверяет detect_flashloader() и добавляет +mboot.status_string в текст ошибки — отличить в логе «host не дождался +ответа» (NoResponse) от настоящего кода ошибки устройства. + +Р13, продолжение (после фикса выше status_string на живом железе показал +НАСТОЯЩУЮ причину — не таймаут, не обрыв связи): flash_erase_region/ +flash_erase_all возвращают status 20106 «FlexSPINOR: Command Failure» — +дженерик-ошибка исполнения команды контроллером FlexSPI (НЕ 20101/20102 +EraseSectorFail/EraseAllFail — до собственно попытки стереть дело не +доходит), причина пока НЕ найдена (см. ниже — одна из гипотез уже +опровергнута на железе). + +Р13, ОПРОВЕРГНУТАЯ гипотеза (не повторять без новых данных): сверка с +boot_utility_log.txt (NXP MCUBootUtility, erase на той же плате проходит +штатно) показала порядок configure-memory(0xC0000007) [probe] → +configure-memory(0xF000000F) [commit FCB] → flash-erase-all — у нас +write_fcb_auto() (0xF000000F) либо не вызывался перед erase вообще +(erase_chip), либо вызывался только после (flash). Гипотеза была: commit +нужен ДО erase, чтобы контроллер полностью сконфигурировался под +erase-команды, а физически записанные им байты «всё равно сотрутся» +следующей erase-командой. **Это ПРОВЕРЕНО и ОПРОВЕРГНУТО на железе**: +добавление write_fcb_auto(mboot) сразу после configure_flexspi(mboot), до +erase, в обоих путях, дало НОВУЮ ошибку — status 10203 «Memory Cumulative +Write» (см. error_codes.py: соседний код IAP_CUMULATIVE_WRITE = +«Flash Memory Region To Be Programmed Is Not Empty») — И СЛОМАЛО ранее +рабочую плату. Значит configure-memory(0xF000000F) — это не «донастройка +контроллера», а НЕМЕДЛЕННАЯ физическая запись FCB во flash прямо в момент +вызова; запись в НЕ стёртую область (то есть в любую ранее прошитую плату) +эту запись сразу проваливает — она не успевает дождаться erase. Правка +отменена (revert). Почему в логе NXP это работало — неизвестно: либо их +0xF000000F-эквивалент в этой конкретной последовательности целится в уже +стёртую область по другой причине, либо это вообще не то, чем кажется на +первый взгляд (лог обрывается ровно на flash-erase-all, что было ДО или +ПОСЛЕ него в их полном сценарии — не зафиксировано). Не полагаться на +сопоставление логов двух разных инструментов без подтверждения по +официальной документации NXP (AN12107) или доступа к ROM-исходникам. + +Р14 (переоценка после доп. вопросов пользователю): AN12107 не описывает +FCB/configure-memory вообще (проверено — pdftotext + grep по всему файлу, +0 совпадений). Форум NXP (RT1064) прямо говорит: configure-memory(0xC0000007) +«используется только для установления соединения» (речь идёт про скорость +FlexSPI), а не для настройки конкретного чипа — версия про «нужен pre-existing +корректный FCB на чипе» отклонена САМИМ пользователем: платы приходят с +завода с гарантированно пустым внешним flash, поэтому у «рабочих» 450 плат +просто не может быть валидного FCB к моменту первого касания. Уточняющие +вопросы дали решающий факт: на ~50 проблемных платах **обычная** прошивка +firmware_test (не только erase/«Другое») тоже падает, и чип/ревизия платы — +**та же**, что у 450 рабочих (не другая партия/поставщик). Значит проблема не +в логике конфигурации FlexSPI и не в конкретном чипе — она проявляется на +случайном подмножестве физически идентичных плат независимо от того, какая +именно flash-команда выполняется первой. Это типичная картина маргинального +электрического контакта (пайка/разводка/питание конкретного экземпляра), +воспроизводимого на erase (самая «тяжёлая» операция по току/длительности — +NOR erase требует внутренней подкачки напряжения), а не логической ошибки +хоста. Софт не может физически починить плохую пайку, но может дать шанс +операции пройти со второй/третьей попытки, если условие временное. Фикс: +retry (см. _run_flash_cmd) вокруг каждой команды, реально трогающей flash-чип +(configure_memory/erase/write) — если контакт временно «плавает», повторная +попытка через паузу может пройти там, где первая не удалась; если ошибка +детерминированная (чип действительно неисправен), retry её не замаскирует — +просто несколько раз повторит тот же честный отказ перед тем, как сдаться. +[Р15: retry не помог — все 3 попытки падали с тем же 20106. Гипотеза +«маргинальный контакт» опровергнута доп. фактами: MCUBootUtility на тех же +платах работает 10/10, а после ОДНОГО её касания плата НАВСЕГДА начинает +работать и в нашей утилите. Это не электрика — это энергонезависимое +состояние чипа. Retry оставлен как безвредная страховка.] + +Р15 — НАЙДЕННАЯ ПРИЧИНА (misread лога в Р13): в логах MCUBootUtility +option word — десятичное 3221225991 = 0xC0000207, а НЕ 0xC0000007, как было +ошибочно прочитано при сверке в Р13 (и как стоило у нас с Фазы 0, унаследовано +от flash_usb.py). Разница — поле quad_mode_setting (биты [11:8]): 2 вместо 0. +quad_mode_setting=2 велит flashloader'у установить QE-бит (Quad Enable, +Status Register 2 bit 1 — формат Winbond) на самом чипе при configure-memory. +QE у Winbond ЭНЕРГОНЕЗАВИСИМЫЙ, и часть партий W25Q128 приходит с завода с +QE=0 (Winbond выпускает варианты с заводским QE=0 и QE=1 под почти одинаковой +маркировкой). Это объясняет ВСЁ наблюдённое детерминированно: + - на чипах с QE=0 наш 0xC0000007 не включал quad-режим, LUT настроен на + quad-команды → чип не отвечает → 20106 Command Failure на ЛЮБОЙ операции + (erase/обычная прошивка/«Другое») — ровно как в поле (~50/500 плат); + - MCUBootUtility (выбор W25Q в External Memory → 0xC0000207) работает + 10/10 и, единожды установив QE=1, НАВСЕГДА «чинит» плату и для нас; + - на 450 «рабочих» платах — чипы из партий с заводским QE=1; + - форумный лог RT1064 с 0xC0000007 не противоречит: MCUBootUtility + подставляет option word по выбранному чипу, там чип другой. +Для чипов с уже установленным QE повторная установка — no-op (это статус- +регистр, не массив — Cumulative Write здесь не существует). Урок: десятичные +значения в чужих логах пересчитывать инструментом, не «на глаз» — misread +одного слова стоил трёх раундов на живом железе, включая один регресс. """ from __future__ import annotations @@ -109,13 +213,36 @@ HAB_OFFSET = 0x1000 FLASHLOADER_LOAD_ADDR = 0x20001C00 FLEXSPI_OPTION_ADDR = 0x2000 -FLEXSPI_OPTION_VALUE = 0xC0000007 +# Р15: 0xC0000207, НЕ 0xC0000007. Поле quad_mode_setting (биты [11:8]) = 2 → +# flashloader при configure-memory устанавливает QE-бит (Quad Enable, Status +# Register 2 bit 1 — формат Winbond W25Q) на самом flash-чипе. QE у Winbond +# энергонезависимый; часть партий W25Q128 приходит с завода с QE=0 — на них +# quad_mode_setting=0 («QE не трогать») оставлял чип в SPI-режиме при +# LUT-таблицах, настроенных на quad-команды → ЛЮБАЯ операция (erase/write) +# падала с 20106 FlexSPINOR Command Failure. Значение снято с живого лога +# NXP MCUBootUtility (fill-memory ... 3221225991 = 0xC0000207 при выбранной +# памяти W25Q). Для чипов с уже установленным QE повторная установка — no-op. +FLEXSPI_OPTION_VALUE = 0xC0000207 FLEXSPI_FCB_VALUE = 0xF000000F # tag=0xF → Write FCB command (auto-config) FLEXSPI_MEMORY_ID = 9 -ERASE_ALL_TIMEOUT_MS = 200_000 # эквивалент blhost -t 200000 (см. Фазу 0, ⚠В2) +# Единый read/write-таймаут HID-команд на всю Flashloader-сессию (configure + +# erase + write) — см. Р13. spsdk-дефолт 2000мс (UsbDevice.__init__) слишком +# короткий для части плат в поле; значение — эквивалент blhost -t 200000. +MCUBOOT_CMD_TIMEOUT_MS = 200_000 FLASHLOADER_WAIT_TIMEOUT_S = 10.0 +# Р14/Р15 — повторные попытки flash-команды. Изначально добавлены под +# гипотезу «маргинальный контакт», которая позже опроверглась (настоящая +# причина — QE-бит, Р15). Оставлены как безвредная defensive-страховка от +# РЕАЛЬНО транзиентных сбоев USB/HID: happy-path не затрагивают (первая же +# успешная попытка возвращает управление), детерминированную ошибку не +# маскируют (после N попыток — тот же честный _fail_command со status_string). +# Компромисс: на действительно битой плате erase теперь может тянуться до +# N×~54с перед отказом — приемлемо, такие платы редки. См. модульный docstring. +CMD_RETRY_ATTEMPTS = 3 +CMD_RETRY_DELAY_S = 0.5 + _HAB_OPTIONS_TEMPLATE = [ "options:", " flags: 0x00", @@ -198,33 +325,80 @@ def detect_cdc() -> bool: return resolve_serial_port(UsbId(_CDC_VID, _CDC_PID)) is not None -def _sdp_still_present() -> bool: +def detect_flashloader() -> bool: + """True если виден Flashloader (см. _FLASHLOADER_DEVICE_ID).""" + return len(MbootUSBInterface.scan(device_id=_FLASHLOADER_DEVICE_ID)) > 0 + + +def _flashloader_still_present() -> bool: """Быстрая проверка «плата ещё на шине» для error-путей (вариант B, Р10). + Проверяет присутствие Flashloader, НЕ BootROM SDP (Р13, исторический + баг): все вызовы _fail_command() приходят из команд McuBoot + (configure_flexspi/write_fcb_*/flash_erase_*/write_memory), которые + в принципе выполняются только после того, как плата спрыгнула с SDP на + Flashloader внутри load_flashloader() — проверка по SDP тут возвращала + бы «нет» ВСЕГДА, независимо от реального состояния платы. + Любая ошибка самой проверки трактуется как «устройства нет»: проверка выполняется только ПОСЛЕ уже случившегося сбоя команды, шина в этот момент нестабильна, и «не смог проверить» практически всегда означает «плату выдернули» (согласовано, RELEASE_ROADMAP.md §B). """ try: - return detect_sdp() + return detect_flashloader() except Exception: # noqa: BLE001 — см. docstring: любой сбой ⇒ считаем обрывом return False -def _fail_command(message: str) -> None: +def _fail_command(mboot: McuBoot, message: str) -> None: """Живая команда spsdk вернула False — переклассификация по варианту B (Р10). Если устройство пропало с шины → обрыв (ConnectionLostError), иначе → - честная ошибка операции (FlashBackendError с прежним текстом). detect_sdp() + честная ошибка операции (FlashBackendError). detect_flashloader() выполняется ТОЛЬКО здесь, в error-пути; happy path не затрагивается. + mboot.status_string добавляется к тексту ошибки во всех случаях (Р13) — + настоящий код статуса spsdk (например «NoResponse» при хостовом + read-таймауте) отличим от кода ошибки, реально сообщённого устройством. :raises ConnectionLostError: устройство исчезло с шины после сбоя команды. :raises FlashBackendError: устройство на месте — ошибка самой операции. """ - if not _sdp_still_present(): - raise ConnectionLostError(f"{message} (устройство пропало с шины)") - raise FlashBackendError(message) + full_message = f"{message} (status: {mboot.status_string})" + if not _flashloader_still_present(): + raise ConnectionLostError(f"{full_message} (устройство пропало с шины)") + raise FlashBackendError(full_message) + + +def _run_flash_cmd(mboot: McuBoot, description: str, cmd: Callable[[], bool]) -> None: + """Выполняет одну flash-команду (configure_memory/erase/write) с повторами. + + Р14: часть плат в поле отдаёт `status FlexSPINOR Command Failure` на + случайной операции с flash-чипом — похоже на маргинальный электрический + контакт (пайка/питание конкретного экземпляра), а не на логическую + ошибку хоста. Если условие временное, повтор через паузу может пройти + там, где первая попытка не удалась; если ошибка детерминированная + (плата действительно неисправна), повтор её не замаскирует — после + исчерпания попыток ошибка всё равно уходит в _fail_command() с честным + текстом. Между попытками проверяется присутствие Flashloader — при + реальном обрыве связи retry не имеет смысла, сразу же уходим в + _fail_command() (см. ConnectionLostError). + + :raises ConnectionLostError: устройство исчезло с шины между попытками. + :raises FlashBackendError: команда не удалась во всех попытках, + устройство на месте. + """ + for attempt in range(1, CMD_RETRY_ATTEMPTS + 1): + if cmd(): + return + if attempt < CMD_RETRY_ATTEMPTS and _flashloader_still_present(): + logger.warning( + f"{description}: попытка {attempt}/{CMD_RETRY_ATTEMPTS} не удалась " + f"(status: {mboot.status_string}), повтор через {CMD_RETRY_DELAY_S}с" + ) + time.sleep(CMD_RETRY_DELAY_S) + continue + _fail_command(mboot, f"{description} вернул False после {attempt} попыт(ок)") def _emit( @@ -318,11 +492,28 @@ def load_flashloader( def configure_flexspi(mboot: McuBoot) -> None: - """Инициализирует FlexSPI NOR контроллер (см. flash_usb.py::configure_flexspi).""" + """Инициализирует FlexSPI NOR контроллер + включает QE-бит чипа (Р15). + + Option word 0xC0000207 (см. комментарий у константы) — flashloader + пробует чип по SFDP и, что критично, устанавливает энергонезависимый + QE-бит на чипах, пришедших с завода с QE=0. Второе option-слово + обнуляется явно — зеркало последовательности NXP MCUBootUtility + (при optionSize=0 оно не должно читаться, но страхуемся от мусора в RAM). + + ВАЖНО для «Другое»: flash() зовёт configure_flexspi() ВСЕГДА, в т.ч. для + custom-бинарей с явным FCB. quad_mode_setting=2 = «QE через Status + Register 2 bit 1, команда 0x31» — это метод Winbond. Все текущие + FcbVariant — Winbond W25Q, для них корректно. Если в список памяти когда- + нибудь добавят не-Winbond чип (другой метод QE — SR1 bit 6, отдельный + регистр и т.п.), этот option word под него нужно будет пересмотреть. + """ mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE) - ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) - if not ok: - _fail_command("configure_memory (FlexSPI init) вернул False") + mboot.fill_memory(FLEXSPI_OPTION_ADDR + 4, 4, 0) + _run_flash_cmd( + mboot, + "configure_memory (FlexSPI init)", + lambda: mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID), + ) def write_fcb_auto(mboot: McuBoot) -> None: @@ -331,9 +522,11 @@ def write_fcb_auto(mboot: McuBoot) -> None: Надёжно проверен только для W25Q128 — см. docstring оригинала. """ mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_FCB_VALUE) - ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) - if not ok: - _fail_command("configure_memory (FCB write) вернул False") + _run_flash_cmd( + mboot, + "configure_memory (FCB write)", + lambda: mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID), + ) def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: @@ -345,9 +538,11 @@ def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: if not fcb_path.exists(): raise FlashBackendError(f"FCB-файл не найден: {fcb_path}") data = fcb_path.read_bytes() - ok = mboot.write_memory(FLASH_BASE, data, mem_id=0) - if not ok: - _fail_command(f"write_memory(FCB {fcb_path.name}) вернул False") + _run_flash_cmd( + mboot, + f"write_memory(FCB {fcb_path.name})", + lambda: mboot.write_memory(FLASH_BASE, data, mem_id=0), + ) # ─── Прошивка / RAM-load / erase ──────────────────────────────────────────── @@ -391,6 +586,7 @@ def flash( return iface = load_flashloader(progress_cb) + iface.device.timeout = MCUBOOT_CMD_TIMEOUT_MS # см. Р13 — до первой команды write_addr = FLASH_BASE + HAB_OFFSET hab_size = hab_bin.stat().st_size @@ -410,9 +606,11 @@ def flash( 0, f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт", ) - ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) - if not ok: - _fail_command("flash_erase_region вернул False") + _run_flash_cmd( + mboot, + "flash_erase_region", + lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0), + ) _emit(progress_cb, "fcb", 0, "Запись FCB") if fcb_path is not None: @@ -427,11 +625,13 @@ def flash( _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 + _run_flash_cmd( + mboot, + "write_memory (HAB-образ)", + lambda: mboot.write_memory( + write_addr, data, mem_id=0, progress_callback=_on_progress + ), ) - if not ok: - _fail_command("write_memory (HAB-образ) вернул False") _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) @@ -452,6 +652,7 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: :raises ConnectionLostError: обрыв USB посреди chip erase. """ iface = load_flashloader(progress_cb) + iface.device.timeout = MCUBOOT_CMD_TIMEOUT_MS # см. Р13 — уже для configure_flexspi try: with McuBoot(iface) as mboot: @@ -459,10 +660,11 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: 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: - _fail_command("flash_erase_all вернул False") + _run_flash_cmd( + mboot, + "flash_erase_all", + lambda: mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID), + ) _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) diff --git a/tools/service_tui/docs/DEV_ARCH.md b/tools/service_tui/docs/DEV_ARCH.md index 73709e1..a9bf892 100644 --- a/tools/service_tui/docs/DEV_ARCH.md +++ b/tools/service_tui/docs/DEV_ARCH.md @@ -288,13 +288,30 @@ FlashBackendError (connection_lost: bool = False) `erase_chip`. - **Вариант B для команд, возвращающих `False` без исключения** (Р10, `_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory` - иногда просто возвращают `False` вместо исключения. В этом случае - `_fail_command()` выполняет быстрый `_sdp_still_present()` (обёрнутый в - `try/except` — любая ошибка самой проверки трактуется как «устройства - нет», т.к. шина к этому моменту уже нестабильна): устройство пропало → - `ConnectionLostError`, устройство на месте → обычный `FlashBackendError` с - текстом ошибки операции. Проверка добавляется **только в error-путь**, на - happy path не влияет. + иногда просто возвращают `False` вместо исключения (`McuBoot(iface)` + создаётся с `cmd_exception=False` — дефолт spsdk, статус оседает в + `status_code`, исключения нет). В этом случае `_fail_command()` выполняет + быстрый `_flashloader_still_present()` (обёрнутый в `try/except` — любая + ошибка самой проверки трактуется как «устройства нет», т.к. шина к этому + моменту уже нестабильна): устройство пропало → `ConnectionLostError`, + устройство на месте → обычный `FlashBackendError` с текстом ошибки + операции + `mboot.status_string`. Проверка добавляется **только в + error-путь**, на happy path не влияет. + **Р13 (полевой баг, ~50/500 плат):** до фикса эта проверка звала + `_sdp_still_present()` — сканировала BootROM SDP VID:PID, хотя к моменту + вызова `_fail_command()` плата уже спрыгнула на Flashloader (другой + VID:PID) внутри `load_flashloader()`. Проверка возвращала «нет» + **всегда**, независимо от реального состояния платы — любой `False` от + Flashloader-команды маскировался под обрыв USB. Одновременно + `flash()`/`flash_erase_region` и `configure_flexspi()` (в обоих путях) не + поднимали `iface.device.timeout` вообще — работали на spsdk-дефолте + 2000мс (`UsbDevice.__init__`), тогда как NXP MCUBootUtility использует + 50–2048с на те же команды. На части плат в поле реальный ответ не + укладывался в 2с → тихий `NO_RESPONSE` → `False` → неверно + классифицировался как «устройство пропало с шины». Фикс: единый + `MCUBOOT_CMD_TIMEOUT_MS` выставляется сразу после `load_flashloader()`, + до первой команды сессии (не только перед `flash_erase_all`, как было + раньше); `_fail_command()` проверяет `detect_flashloader()`, не SDP. - **`Flasher._run_flash_op()`** (`flasher.py`) конвертирует `FlashBackendError` обратно в `FlashResult(ok=False, connection_lost=exc.connection_lost)` + событие `FlashProgress(phase="error")`. @@ -352,8 +369,9 @@ AppFrame { Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются `nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 — -для них auto-config Flashloader (`configure-memory 0xC0000007` → -`0xF000000F`, см. `HOW_TO_FLASH.md`) достаточен. Для сторонних/легаси +для них auto-config Flashloader (`configure-memory 0xC0000207` → +`0xF000000F`, см. `HOW_TO_FLASH.md`; до Р15 было `0xC0000007` — без +установки QE-бита, см. §8.4) достаточен. Для сторонних/легаси бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader не документирован как надёжный для 4-байтной адресации, а сами бинарники приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат, @@ -449,12 +467,72 @@ def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: `fcb_path` в `flash_backend.flash()` — путь без него не тронут: штатная прошивка идёт через auto-config `write_fcb_auto`, как и раньше. -Таймаут для `flash_erase_all` (chip erase) увеличен до `ERASE_ALL_TIMEOUT_MS -= 200_000` мс (эквивалент `blhost -t 200000`): W25Q512 стирается заметно -дольше W25Q128, дефолтного таймаута McuBoot не хватало. -`flash_erase_region` (стирание пары секторов под FCB+HAB при обычной -прошивке) не трогали — там масштаб на порядки меньше, дефолта достаточно -независимо от чипа. +Таймаут HID-команд (`iface.device.timeout`) поднят до `MCUBOOT_CMD_TIMEOUT_MS += 200_000` мс (эквивалент `blhost -t 200000`) на всю Flashloader-сессию +целиком — `configure_flexspi()`, `flash_erase_region`/`flash_erase_all`, +`write_fcb_*`, `write_memory`, выставляется сразу после `load_flashloader()`, +до первой команды. Изначально (Фаза 0) считалось, что `flash_erase_region` +(пара секторов под FCB+HAB) укладывается в spsdk-дефолт 2000мс независимо от +чипа и трогать его не нужно — на живом парке плат это оказалось не так +(Р13, ~50/500 плат из полевых логов не укладывались в 2с), см. §6.2. + +После фикса выше hardware-тест на «отбракованной» плате показал НАСТОЯЩУЮ +причину сбоя вместо таймаута: `status: FlexSPINOR: Command Failure` (spsdk +`StatusCode` 20106 — дженерик-ошибка исполнения команды контроллером +FlexSPI, не специализированный `EraseSectorFail`/`EraseAllFail`). **Причина +всё ещё не найдена.** Первая гипотеза (commit FCB `0xF000000F` нужен ДО +erase, по аналогии с `boot_utility_log.txt` NXP MCUBootUtility) была +**проверена на железе и опровергнута**: `configure-memory(0xF000000F)` +оказался немедленной физической записью FCB во flash, а не безобидной +донастройкой контроллера — на НЕ стёртой области (любая ранее прошитая +плата) запись сразу проваливается со status 10203 «Memory Cumulative +Write», и это сломало ранее рабочую плату. Правка отменена. Полная +хронология и текст урока — в `flash_backend.py`, docstring, раздел «Р13, +ОПРОВЕРГНУТАЯ гипотеза». + +**Р14 — переоценка после дополнительных данных.** AN12107 не описывает +FCB/configure-memory вообще (проверено, 0 совпадений по всему файлу). +Уточняющие вопросы дали решающие факты: (1) на тех же ~50 проблемных платах +падает и **обычная** прошивка firmware_test, не только erase/«Другое»; (2) +чип и ревизия платы — **та же**, что у 450 рабочих, не другая партия. Значит +дело не в конкретной flash-команде и не в чужеродном чипе — сбой проявляется +на случайном подмножестве физически идентичных плат независимо от того, +какая именно команда выполняется первой. Это картина маргинального +электрического контакта конкретного экземпляра (пайка/питание), проявляется +сильнее всего на erase (внутренняя подкачка напряжения NOR, самая +«тяжёлая» операция по току среди flash-команд), а не логической ошибки на +стороне хоста — программно починить плохую пайку нельзя, но можно дать +операции второй/третий шанс, если условие временное. + +Фикс: `_run_flash_cmd()` — retry (`CMD_RETRY_ATTEMPTS = 3`, +`CMD_RETRY_DELAY_S = 0.5`с) вокруг каждой команды, реально трогающей +flash-чип (`configure_memory` в `configure_flexspi`/`write_fcb_auto`, +`write_memory` в `write_fcb_explicit`/основной записи HAB-образа, +`flash_erase_region`, `flash_erase_all`). Между попытками проверяется +`detect_flashloader()` — при реальном обрыве связи retry не имеет смысла, +сразу уходит в `ConnectionLostError`, как раньше. Если ошибка +детерминированная (плата физически неисправна), retry её не маскирует — +после исчерпания попыток `_fail_command()` получает управление с тем же +`mboot.status_string`, что и раньше, просто с пометкой «после N попыток». +Hardware-тест: retry НЕ помог (все 3 попытки — тот же 20106), гипотеза +«маргинальный контакт» опровергнута доп. фактами (MCUBootUtility на тех же +платах — 10/10; после одного её касания плата навсегда работает и у нас). +Retry оставлен как безвредная страховка. + +**Р15 — найденная причина (QE-бит).** Пересчёт десятичных значений в логах +MCUBootUtility инструментом (не «на глаз», как в Р13 — тогда значение было +прочитано неверно, что увело диагностику в сторону на три раунда) показал: +их option word — `3221225991 = 0xC0000207`, наш был `0xC0000007`. Разница — +поле quad_mode_setting (биты [11:8]) = 2: flashloader при `configure-memory` +устанавливает **энергонезависимый** QE-бит (Quad Enable, Status Register 2 +bit 1, формат Winbond) на самом чипе. Часть партий W25Q128 приходит с +завода с QE=0 — на них наш вариант оставлял чип в SPI-режиме при LUT, +настроенных на quad-команды → 20106 на любой операции. Один прогон +MCUBootUtility устанавливал QE=1 навсегда — отсюда «после их утилиты наша +начинает работать». Исправлено в `FLEXSPI_OPTION_VALUE` (`flash_backend.py` ++ `tools/host/flash_usb.py`), плюс явное обнуление второго option-слова — +полное зеркало последовательности NXP. Для чипов с уже установленным QE +повторная установка — no-op (статус-регистр, не массив памяти). ### 8.5 UI (`flash.py`) diff --git a/tools/service_tui/tests/test_flash_backend.py b/tools/service_tui/tests/test_flash_backend.py index 820c536..47e557c 100644 --- a/tools/service_tui/tests/test_flash_backend.py +++ b/tools/service_tui/tests/test_flash_backend.py @@ -67,6 +67,16 @@ def test_detect_cdc_not_found(monkeypatch): assert fb.detect_cdc() is False +def test_detect_flashloader_found(monkeypatch): + monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[Mock()])) + assert fb.detect_flashloader() is True + + +def test_detect_flashloader_not_found(monkeypatch): + monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[])) + assert fb.detect_flashloader() is False + + # ─── wait_for_flashloader ──────────────────────────────────────────────── @@ -157,17 +167,30 @@ def test_load_flashloader_happy_path(monkeypatch, events): def test_configure_flexspi_ok(): mboot = Mock(configure_memory=Mock(return_value=True)) fb.configure_flexspi(mboot) - mboot.fill_memory.assert_called_once_with( - fb.FLEXSPI_OPTION_ADDR, 4, fb.FLEXSPI_OPTION_VALUE - ) + # Р15: option0 (0xC0000207 — включая QE-бит) + явное обнуление option1, + # зеркало последовательности NXP MCUBootUtility. + assert mboot.fill_memory.call_args_list == [ + ((fb.FLEXSPI_OPTION_ADDR, 4, fb.FLEXSPI_OPTION_VALUE),), + ((fb.FLEXSPI_OPTION_ADDR + 4, 4, 0),), + ] mboot.configure_memory.assert_called_once_with( fb.FLEXSPI_OPTION_ADDR, fb.FLEXSPI_MEMORY_ID ) +def test_flexspi_option_value_sets_winbond_qe(): + """Р15-регрессия: quad_mode_setting (биты [11:8]) обязан быть 2 — + «установить QE-бит в Status Register 2 bit 1» (Winbond W25Q). Со значением + 0 чипы из партий с заводским QE=0 не работают вообще (20106 на любой + операции) — см. модульный docstring, Р15.""" + assert (fb.FLEXSPI_OPTION_VALUE >> 8) & 0xF == 2 + assert fb.FLEXSPI_OPTION_VALUE == 0xC0000207 + + def test_configure_flexspi_fail(monkeypatch): # Плата на месте → честная ошибка операции, НЕ обрыв (вариант B, Фаза 4a). - monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + monkeypatch.setattr(fb.time, "sleep", Mock()) # не ждать реально между retry (Р14) mboot = Mock(configure_memory=Mock(return_value=False)) with pytest.raises(fb.FlashBackendError) as ei: fb.configure_flexspi(mboot) @@ -200,7 +223,8 @@ def test_write_fcb_explicit_ok(tmp_path): def test_write_fcb_explicit_write_fails(monkeypatch, tmp_path): - monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + monkeypatch.setattr(fb.time, "sleep", Mock()) # не ждать реально между retry (Р14) fcb = tmp_path / "w25q128_fdcb.bin" fcb.write_bytes(b"\xab" * 512) mboot = Mock(write_memory=Mock(return_value=False)) @@ -321,8 +345,9 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path): hab_bin.write_bytes(b"\xd1") monkeypatch.setattr( - fb, "detect_sdp", Mock(return_value=True) + fb, "detect_flashloader", Mock(return_value=True) ) # плата на месте → честная ошибка + monkeypatch.setattr(fb.time, "sleep", Mock()) # не ждать реально между retry (Р14) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) _mock_mcuboot_ctx(monkeypatch, write_memory=False) @@ -367,7 +392,7 @@ def test_erase_chip_happy_path(monkeypatch, events): fb.erase_chip(progress_cb=_collector(events)) - assert flashloader_iface.device.timeout == fb.ERASE_ALL_TIMEOUT_MS + assert flashloader_iface.device.timeout == fb.MCUBOOT_CMD_TIMEOUT_MS ctx.flash_erase_all.assert_called_once_with(mem_id=fb.FLEXSPI_MEMORY_ID) ctx.reset.assert_called_once_with(reopen=False) assert _phases(events) == ["configure", "erase", "reset", "done"] @@ -375,8 +400,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) + fb, "detect_flashloader", Mock(return_value=True) ) # плата на месте → честная ошибка + monkeypatch.setattr(fb.time, "sleep", Mock()) # не ждать реально между retry (Р14) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock())) _mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) @@ -511,25 +537,83 @@ def test_connection_lost_tuple_covers_timeout(): 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_flashloader_still_present_swallows_check_error(monkeypatch): + """§B: ошибка самой проверки detect_flashloader() трактуется как «устройства нет».""" + monkeypatch.setattr( + fb, "detect_flashloader", Mock(side_effect=RuntimeError("bus gone")) + ) + assert fb._flashloader_still_present() is False def test_fail_command_device_gone_is_connection_lost(monkeypatch): - monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=False)) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False)) + mboot = Mock(status_string="NoResponse") with pytest.raises(fb.ConnectionLostError) as ei: - fb._fail_command("flash_erase_all вернул False") + fb._fail_command(mboot, "flash_erase_all вернул False") assert ei.value.connection_lost is True + assert "NoResponse" in str(ei.value) def test_fail_command_device_present_is_plain_error(monkeypatch): - monkeypatch.setattr(fb, "detect_sdp", Mock(return_value=True)) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + mboot = Mock(status_string="kStatus_FlashCommandFailure") with pytest.raises(fb.FlashBackendError) as ei: - fb._fail_command("flash_erase_all вернул False") + fb._fail_command(mboot, "flash_erase_all вернул False") assert not isinstance(ei.value, fb.ConnectionLostError) assert ei.value.connection_lost is False + assert "kStatus_FlashCommandFailure" in str(ei.value) + + +# ─── Р14: retry маргинального контакта (_run_flash_cmd) ────────────────── + + +def test_run_flash_cmd_succeeds_first_try(monkeypatch): + mboot = Mock(status_string="Success") + cmd = Mock(return_value=True) + fb._run_flash_cmd(mboot, "test_cmd", cmd) + cmd.assert_called_once() + + +def test_run_flash_cmd_retries_then_succeeds(monkeypatch): + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + monkeypatch.setattr(fb.time, "sleep", Mock()) + mboot = Mock(status_string="NoResponse") + cmd = Mock(side_effect=[False, False, True]) + + fb._run_flash_cmd(mboot, "test_cmd", cmd) + + assert cmd.call_count == 3 + + +def test_run_flash_cmd_gives_up_after_max_attempts_device_present(monkeypatch): + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + sleep_mock = Mock() + monkeypatch.setattr(fb.time, "sleep", sleep_mock) + mboot = Mock(status_string="kStatus_FlashCommandFailure") + cmd = Mock(return_value=False) + + with pytest.raises(fb.FlashBackendError) as ei: + fb._run_flash_cmd(mboot, "test_cmd", cmd) + + assert not isinstance(ei.value, fb.ConnectionLostError) + assert cmd.call_count == fb.CMD_RETRY_ATTEMPTS + assert sleep_mock.call_count == fb.CMD_RETRY_ATTEMPTS - 1 + assert f"после {fb.CMD_RETRY_ATTEMPTS} попыт" in str(ei.value) + + +def test_run_flash_cmd_bails_immediately_if_device_gone(monkeypatch): + """Обрыв связи между попытками — retry бессмыслен, не тратим время.""" + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False)) + sleep_mock = Mock() + monkeypatch.setattr(fb.time, "sleep", sleep_mock) + mboot = Mock(status_string="NoResponse") + cmd = Mock(return_value=False) + + with pytest.raises(fb.ConnectionLostError): + fb._run_flash_cmd(mboot, "test_cmd", cmd) + + cmd.assert_called_once() + sleep_mock.assert_not_called() def test_load_flashloader_timeout_is_connection_lost(monkeypatch): @@ -581,19 +665,79 @@ def test_flash_write_false_device_gone_is_connection_lost( 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)) + monkeypatch.setattr(fb, "detect_flashloader", 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.""" + """Гейт 4a #2a: flash_erase_all=False + detect_flashloader=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)) + monkeypatch.setattr(fb, "detect_flashloader", 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) + + +# ─── Р13: единый таймаут ДО первой Flashloader-команды (не только erase) ── + + +def test_flash_sets_timeout_before_first_command(monkeypatch, events, tmp_path): + """Полевой баг: flash_erase_region молча падал по spsdk-дефолту 2000мс, + т.к. flash() никогда не поднимал iface.device.timeout. Таймаут должен + быть выставлен ДО configure_flexspi(), а не только перед erase.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 15) + + flashloader_iface = Mock() + flashloader_iface.device.timeout = 2000 # spsdk-дефолт, UsbDevice.__init__ + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=flashloader_iface)) + ctx = _mock_mcuboot_ctx(monkeypatch) + + seen_timeouts = [] + ctx.configure_memory.side_effect = ( + lambda *a, **k: seen_timeouts.append(flashloader_iface.device.timeout) or True + ) + + fb.flash(hab_bin, progress_cb=_collector(events)) + + assert seen_timeouts, "configure_memory ни разу не вызван — тест не проверяет ничего" + assert all(t == fb.MCUBOOT_CMD_TIMEOUT_MS for t in seen_timeouts) + + +def test_erase_chip_sets_timeout_before_configure(monkeypatch, events): + """Тот же баг на шаге раньше: до фикса timeout поднимался ПОСЛЕ + configure_flexspi(), значит сам configure всё ещё шёл на 2000мс.""" + flashloader_iface = MagicMock() + flashloader_iface.device.timeout = 2000 + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=flashloader_iface)) + ctx = _mock_mcuboot_ctx(monkeypatch) + + seen_timeouts = [] + ctx.configure_memory.side_effect = ( + lambda *a, **k: seen_timeouts.append(flashloader_iface.device.timeout) or True + ) + + fb.erase_chip(progress_cb=_collector(events)) + + assert seen_timeouts, "configure_memory ни разу не вызван — тест не проверяет ничего" + assert all(t == fb.MCUBOOT_CMD_TIMEOUT_MS for t in seen_timeouts) + + +# ─── Р13, ОПРОВЕРГНУТАЯ гипотеза — commit FCB (0xF000000F) ДО erase ─────── +# +# Была здесь как test_flash_commits_fcb_before_erase / +# test_erase_chip_commits_fcb_before_erase, пинила write_fcb_auto(mboot) +# сразу после configure_flexspi(mboot), до erase (по аналогии с +# boot_utility_log.txt — NXP MCUBootUtility на той же плате). Проверено на +# живом железе (2026-07-13) и ОПРОВЕРГНУТО: configure-memory(0xF000000F) +# физически пишет FCB во flash немедленно, а не просто «донастраивает +# контроллер» — на НЕ стёртой области (то есть на любой ранее прошитой +# плате) запись сразу проваливается со status 10203 «Memory Cumulative +# Write». Сломало ранее рабочую плату. Правка отменена — см. docstring +# модуля flash_backend.py, раздел «Р13, ОПРОВЕРГНУТАЯ гипотеза», не +# повторять без подтверждения по официальной документации NXP. From d9fb813be76da3f502c0078e01f783a6fd08a6b4 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Mon, 13 Jul 2026 14:55:27 +0300 Subject: [PATCH 2/2] # service_tui: 0.2.1 small UI fixes --- tools/service_tui/app/flash_backend.py | 136 +++++++++++- tools/service_tui/app/screens/flash.py | 30 ++- tools/service_tui/docs/DEV_ARCH.md | 26 ++- tools/service_tui/pyproject.toml | 2 +- tools/service_tui/tests/test_flash_backend.py | 198 +++++++++++++++++- 5 files changed, 369 insertions(+), 23 deletions(-) diff --git a/tools/service_tui/app/flash_backend.py b/tools/service_tui/app/flash_backend.py index a908036..2470dab 100644 --- a/tools/service_tui/app/flash_backend.py +++ b/tools/service_tui/app/flash_backend.py @@ -125,6 +125,40 @@ QE=0 (Winbond выпускает варианты с заводским QE=0 и регистр, не массив — Cumulative Write здесь не существует). Урок: десятичные значения в чужих логах пересчитывать инструментом, не «на глаз» — misread одного слова стоил трёх раундов на живом железе, включая один регресс. + +Р16 (пожелание с производства/сервиса, не баг): часть плат попадает на стенд +с уже занятой W25Q — flash_erase_region в flash() стирает только под новый +образ, хвост за границей erase_size (от прошивки БОЛЬШЕГО размера раньше) +остаётся нетронутым. Фикс: перед erase — быстрый _is_blank() на первый +BLANK_CHECK_SIZE=0x1000 байт (стёртый NOR физически 0xFF); если не пусто — +flash_erase_all вместо flash_erase_region. Один сектор — компромисс: FCB+ +вектора всегда пишутся первыми, поэтому непустой чип почти всегда «виден» +уже там, а читать больше — это доп. round-trip на КАЖДУЮ прошивку, не +только на проблемные платы. erase_chip() не тронут — «Очистить память» и +так всегда полный. + +Р17 (полевой отчёт: "USB-соединение потеряно при загрузке Flashloader: +SDP: Connection issue -> SPSDK: Invalid size of written bytes has been +detected: -1 != 1025" — плата физически осталась на шине, WaitingScreen +почти сразу переоткрывал FlashScreen, статус не успевал прочитаться). +_fail_command() (вариант B, Р10/Р13) проверяет присутствие платы перед тем, +как объявить обрыв — но ЭТО применялось только к пути «команда вернула +False». Все 4 места, ловящие _CONNECTION_LOST_EXCEPTIONS как ИСКЛЮЧЕНИЕ +(load_flashloader() — SDP-фаза, flash() ram_only-ветка, flash() основной +McuBoot-блок, erase_chip()), безусловно считали любой SPSDKConnectionError/ +SPSDKTimeoutError обрывом сессии, не проверяя, жива ли плата. Одиночный сбой +HID-записи (bytes_written=-1, см. пример выше) — это не обязательно обрыв: +плата может остаться на шине. Фикс: все 4 места теперь проверяют присутствие +перед классификацией — _sdp_still_present() (новый, симметричный +_flashloader_still_present, для фаз ДО перехода на Flashloader — SDP-часть +load_flashloader() и ram_only) или _flashloader_still_present() (для фаз +ПОСЛЕ — основной McuBoot-блок flash()/erase_chip()). Плата на месте → +FlashBackendError (честная ошибка, остаёмся на экране — тот же путь, что +уже был для варианта B); плата пропала → ConnectionLostError, как раньше. +Ничего в flasher.py/screens не менялось — Flasher._run_flash_op() уже читал +exc.connection_lost с любого FlashBackendError, UI уже умел оставаться на +FlashScreen при connection_lost=False (Гейт 4a, вариант 2) — эта правка +просто перестала СИСТЕМАТИЧЕСКИ обходить эту логику для exception-путей. """ from __future__ import annotations @@ -243,6 +277,18 @@ FLASHLOADER_WAIT_TIMEOUT_S = 10.0 CMD_RETRY_ATTEMPTS = 3 CMD_RETRY_DELAY_S = 0.5 +# Р16 — обнаружение непустого Flash перед прошивкой (пожелание с производства/ +# сервиса: часть плат попадает на стенд с уже занятой W25Q). flash_erase_region +# стирает только под новый образ — если на чипе раньше лежал образ БОЛЬШЕГО +# размера, хвост за границей erase_size остаётся нетронутым. Один быстрый +# read_memory на BLANK_CHECK_SIZE байт (стёртый NOR физически 0xFF, надёжный +# и однозначный сигнал) решает, стирать узкую область или чип целиком. +# Размер — один сектор (граница erase-гранулярности): FCB+вектора всегда +# пишутся первыми, поэтому непустой чип почти всегда «виден» уже в первом +# секторе — читать больше ради надёжности не нужно, но и не бесплатно (это +# доп. round-trip'ы на КАЖДУЮ прошивку, не только на проблемные платы). +BLANK_CHECK_SIZE = 0x1000 + _HAB_OPTIONS_TEMPLATE = [ "options:", " flags: 0x00", @@ -351,6 +397,23 @@ def _flashloader_still_present() -> bool: return False +def _sdp_still_present() -> bool: + """Быстрая проверка присутствия платы в режиме SDP (Р17). + + Симметрично _flashloader_still_present(), но для ДРУГОЙ фазы: SDP-часть + load_flashloader() и ram_only-ветка flash() падают ДО перехода на + Flashloader — устройство физически ещё не успело туда спрыгнуть, здесь + корректно проверять именно SDP (не наоборот, как исторически было в Р13). + + Любая ошибка самой проверки трактуется как «устройства нет» — та же + логика, что и в _flashloader_still_present(). + """ + try: + return detect_sdp() + except Exception: # noqa: BLE001 + return False + + def _fail_command(mboot: McuBoot, message: str) -> None: """Живая команда spsdk вернула False — переклассификация по варианту B (Р10). @@ -401,6 +464,22 @@ def _run_flash_cmd(mboot: McuBoot, description: str, cmd: Callable[[], bool]) -> _fail_command(mboot, f"{description} вернул False после {attempt} попыт(ок)") +def _is_blank(mboot: McuBoot, address: int, length: int) -> bool: + """True если [address, address+length) полностью стёрт (Р16). + + Стёртый NOR физически 0xFF на уровне бит — однозначный сигнал, в отличие + от догадок по контрольным суммам/сигнатурам. Не оборачивается в + _run_flash_cmd: это не команда с состоянием «выполнена/не выполнена», а + просто чтение — если оно не удалось (data пустой или None, оба falsy), + считаем «не пусто» и уходим в полный erase — безопасный дефолт, если не + смогли определить реальное состояние чипа. + """ + data = mboot.read_memory(address, length) + if not data: + return False + return all(b == 0xFF for b in data) + + def _emit( progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str ) -> None: @@ -479,6 +558,12 @@ def load_flashloader( sdp.write_file(FLASHLOADER_LOAD_ADDR, data) sdp.jump_and_run(FLASHLOADER_LOAD_ADDR) except _CONNECTION_LOST_EXCEPTIONS as exc: + if _sdp_still_present(): + # Р17: одиночный сбой HID-записи (напр. bytes_written=-1), плата + # физически осталась на шине — честная ошибка, не обрыв сессии. + raise FlashBackendError( + f"Ошибка при загрузке Flashloader (плата на месте): {exc}" + ) from exc raise ConnectionLostError( f"USB-соединение потеряно при загрузке Flashloader: {exc}" ) from exc @@ -579,6 +664,10 @@ def flash( sdp.write_file(addr, hab_bin.read_bytes()) sdp.jump_and_run(addr) except _CONNECTION_LOST_EXCEPTIONS as exc: + if _sdp_still_present(): # Р17, см. load_flashloader() + raise FlashBackendError( + f"Ошибка при RAM-загрузке (плата на месте): {exc}" + ) from exc raise ConnectionLostError( f"USB-соединение потеряно при RAM-загрузке: {exc}" ) from exc @@ -600,17 +689,34 @@ def flash( _emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR") configure_flexspi(mboot) - _emit( - progress_cb, - "erase", - 0, - f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт", - ) - _run_flash_cmd( - mboot, - "flash_erase_region", - lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0), - ) + _emit(progress_cb, "erase", 0, "Проверка состояния Flash") + if _is_blank(mboot, FLASH_BASE, BLANK_CHECK_SIZE): + _emit( + progress_cb, + "erase", + 0, + f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт", + ) + _run_flash_cmd( + mboot, + "flash_erase_region", + lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0), + ) + else: + # Р16: на чипе уже есть данные (возможно, от прошивки БОЛЬШЕГО + # размера) — узкий erase_region оставил бы хвост за границей + # нового образа нетронутым. Стираем чип целиком. + _emit( + progress_cb, + "erase", + 0, + "Обнаружены данные во Flash — полная очистка (~30с)", + ) + _run_flash_cmd( + mboot, + "flash_erase_all", + lambda: mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID), + ) _emit(progress_cb, "fcb", 0, "Запись FCB") if fcb_path is not None: @@ -636,6 +742,10 @@ def flash( _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) except _CONNECTION_LOST_EXCEPTIONS as exc: + if _flashloader_still_present(): # Р17, см. load_flashloader() + raise FlashBackendError( + f"Ошибка во время прошивки (плата на месте): {exc}" + ) from exc raise ConnectionLostError( f"USB-соединение потеряно во время прошивки: {exc}" ) from exc @@ -669,6 +779,10 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None: _emit(progress_cb, "reset", 0, "Reset") mboot.reset(reopen=False) except _CONNECTION_LOST_EXCEPTIONS as exc: + if _flashloader_still_present(): # Р17, см. load_flashloader() + raise FlashBackendError( + f"Ошибка во время chip erase (плата на месте): {exc}" + ) from exc raise ConnectionLostError( f"USB-соединение потеряно во время chip erase: {exc}" ) from exc diff --git a/tools/service_tui/app/screens/flash.py b/tools/service_tui/app/screens/flash.py index c4e0b32..c9a0df0 100644 --- a/tools/service_tui/app/screens/flash.py +++ b/tools/service_tui/app/screens/flash.py @@ -7,6 +7,7 @@ flash.py — экран прошивки (режим A). from __future__ import annotations +import asyncio import logging from pathlib import Path from typing import Optional @@ -36,6 +37,14 @@ from .connection_watcher import ConnectionLost, ConnectionWatcherMixin logger = logging.getLogger(__name__) +# Пожелание с производства/сервиса: после успешной прошивки Production/Custom +# переход на WaitingScreen происходил мгновенно — оператор не успевал прочитать +# статус в #flash-log (firmware_test в этой паузе не нуждается — там уже есть +# 40с в PostFlashScreen, см. app.py::_on_flash_done). Кнопки остаются +# заблокированными на время паузы, чтобы не запустить новую операцию поверх +# уходящего экрана. +_RESULT_HOLD_S = 3.0 + class FlashScreen(Screen, ConnectionWatcherMixin): """ @@ -248,10 +257,16 @@ class FlashScreen(Screen, ConnectionWatcherMixin): fcb_variant=preset.fcb_variant, progress_cb=self._on_progress, ) - self._set_busy(False) self._finish_progress(result.ok) self._log("✅ Готово" if result.ok else "❌ Ошибка") + if result.ok and target != FlashTarget.FIRMWARE_TEST: + # Держим #flash-log на экране, пока оператор не прочитает статус — + # без этого экран уходил на WaitingScreen в тот же тик, что и лог. + await asyncio.sleep(_RESULT_HOLD_S) + + self._set_busy(False) + if result.ok: self.post_message( self.FlashDone(success=True, target=target, preset=preset) @@ -358,6 +373,17 @@ class FlashScreen(Screen, ConnectionWatcherMixin): def _log(self, msg: str) -> None: try: - self.query_one("#flash-log", Log).write_line(msg) + log = self.query_one("#flash-log", Log) + log.write_line(msg) + # Textual 8.x, Log.write_lines(): is_vertical_scroll_end читается + # ДО добавления новой строки, не после (см. _log.py в textual). + # Несколько write_line() подряд быстрее, чем успевает осесть + # layout (именно так летят события load_flashloader/configure/ + # erase-decision из фонового потока), — снэпшот «были внизу» + # устаревает, и auto_scroll=True на самом Log молча не срабатывает + # для этих строк. Фаза "write" (медленнее, с троттлингом) успевает + # осесть между вызовами — там штатно и без этого. Принудительный + # scroll_end() обходит устаревший снэпшот целиком, а не чинит его. + log.scroll_end(animate=False, immediate=True) except NoMatches: pass diff --git a/tools/service_tui/docs/DEV_ARCH.md b/tools/service_tui/docs/DEV_ARCH.md index a9bf892..3933457 100644 --- a/tools/service_tui/docs/DEV_ARCH.md +++ b/tools/service_tui/docs/DEV_ARCH.md @@ -285,7 +285,19 @@ FlashBackendError (connection_lost: bool = False) `SPSDKError`, но **не** `SPSDKConnectionError`; read-фаза после write может отдать голый таймаут вместо connection error). Ловятся кортежем на всех точках отказа: `load_flashloader`, `flash` (основная + `ram_only` ветки), - `erase_chip`. + `erase_chip`. **Р17:** до фикса эти 4 места безусловно поднимали + `ConnectionLostError` на любое такое исключение, не проверяя, жива ли + плата (в отличие от варианта B ниже, который проверяет) — одиночный сбой + HID-записи (`bytes_written=-1`, реальный полевой пример: плата осталась на + шине, `WaitingScreen` почти сразу переоткрывал `FlashScreen`, оператор не + успевал прочитать причину) маскировался под полноценный обрыв сессии. + Теперь все 4 места сначала проверяют присутствие — `_sdp_still_present()` + для фаз ДО перехода на Flashloader (SDP-часть `load_flashloader()`, + `ram_only`) или `_flashloader_still_present()` для фаз после (основной + `McuBoot`-блок `flash()`/`erase_chip()`) — и при «плата на месте» поднимают + обычный `FlashBackendError` вместо `ConnectionLostError`, что автоматически + заводит их в тот же путь «остаёмся на FlashScreen», что и вариант B ниже — + без изменений в `flasher.py`/UI. - **Вариант B для команд, возвращающих `False` без исключения** (Р10, `_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory` иногда просто возвращают `False` вместо исключения (`McuBoot(iface)` @@ -755,6 +767,18 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл "естественной" (auto) шириной — центрируется именно через `Center()`. Важно не путать эти два случая (`#waiting-version` — первый случай, `#post-flash-title`/`#post-flash-instruction` — второй). +- **`Log(auto_scroll=True)` не гарантирует автопрокрутку на каждой строке.** + `Log.write_lines()` (`textual/widgets/_log.py`) читает + `self.is_vertical_scroll_end` **до** добавления новой строки в буфер, не + после, и скроллит только если снэпшот был `True`. Несколько `write_line()` + подряд быстрее, чем успевает осесть layout между вызовами (характерно для + событий `load_flashloader`/`configure`/`erase`-решения, летящих из + фонового потока почти одновременно) — снэпшот устаревает, и часть строк + проскакивает без скролла, хотя `auto_scroll=True` стоит. Медленные, + троттлированные события (`write`-фаза) успевают осесть между вызовами — + там штатно. Обходится принудительным `log.scroll_end(animate=False, + immediate=True)` сразу после каждого `write_line()` в вызывающем коде + (`FlashScreen._log()`) — не чинит `is_vertical_scroll_end`, а обходит его. --- diff --git a/tools/service_tui/pyproject.toml b/tools/service_tui/pyproject.toml index 8ddc3c4..7dd8427 100644 --- a/tools/service_tui/pyproject.toml +++ b/tools/service_tui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "service-tui" -version = "0.2.0" +version = "0.2.1" description = "TUI сервисного инженера для диагностики платы MIMXRT1052" requires-python = ">=3.11" dependencies = [ diff --git a/tools/service_tui/tests/test_flash_backend.py b/tools/service_tui/tests/test_flash_backend.py index 47e557c..bece551 100644 --- a/tools/service_tui/tests/test_flash_backend.py +++ b/tools/service_tui/tests/test_flash_backend.py @@ -240,12 +240,15 @@ def test_write_fcb_explicit_write_fails(monkeypatch, tmp_path): def _mock_mcuboot_ctx(monkeypatch, **method_returns): """Подменяет fb.McuBoot на context-manager мок с заданными return_value у fill_memory/configure_memory/flash_erase_region/write_memory (все True - по умолчанию, кроме явно переопределённых).""" + по умолчанию, кроме явно переопределённых). read_memory по умолчанию + возвращает «пустой чип» (Р16) — существующие happy-path тесты ожидают + flash_erase_region, не flash_erase_all.""" defaults = dict( configure_memory=True, flash_erase_region=True, flash_erase_all=True, write_memory=True, + read_memory=b"\xff" * fb.BLANK_CHECK_SIZE, ) defaults.update(method_returns) @@ -295,6 +298,41 @@ def test_flash_ram_only_no_sdp(monkeypatch, events, tmp_path): fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events)) +def test_flash_ram_only_connection_error_device_gone(monkeypatch, events, tmp_path): + """Р17: SPSDKConnectionError в ram_only + плата пропала → ConnectionLostError.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1") + + # Первый scan — обнаружение SDP-устройства, второй — _sdp_still_present() + # после сбоя. + monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(side_effect=[[Mock()], []])) + mock_sdp_ctx = MagicMock() + mock_sdp_ctx.__enter__.return_value = mock_sdp_ctx + mock_sdp_ctx.write_file.side_effect = fb.SPSDKConnectionError("bus gone") + monkeypatch.setattr(fb, "SDP", Mock(return_value=mock_sdp_ctx)) + + with pytest.raises(fb.ConnectionLostError): + fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events)) + + +def test_flash_ram_only_connection_error_device_present(monkeypatch, events, tmp_path): + """Р17: тот же SPSDKConnectionError, но плата всё ещё в SDP-режиме → + честная ошибка, не обрыв.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1") + + monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()])) + mock_sdp_ctx = MagicMock() + mock_sdp_ctx.__enter__.return_value = mock_sdp_ctx + mock_sdp_ctx.write_file.side_effect = fb.SPSDKConnectionError("глюк одной записи") + monkeypatch.setattr(fb, "SDP", Mock(return_value=mock_sdp_ctx)) + + with pytest.raises(fb.FlashBackendError) as ei: + fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events)) + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False + + def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path): hab_bin = tmp_path / "fw_hab.bin" hab_bin.write_bytes(b"\xd1" + b"\x00" * 127) @@ -318,7 +356,16 @@ def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path): assert "progress_callback" in wkwargs ctx.reset.assert_called_once_with(reopen=False) - assert _phases(events) == ["configure", "erase", "fcb", "write", "reset", "done"] + # Р16: два "erase"-события — проверка блочности + собственно стирание. + assert _phases(events) == [ + "configure", + "erase", + "erase", + "fcb", + "write", + "reset", + "done", + ] assert events[-1].percent == 100 @@ -382,6 +429,63 @@ def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path): assert write_events[-1].percent == 100 +# ─── Р16: детект непустого Flash → полный erase вместо region ──────────── + + +def test_is_blank_true_for_erased_flash(): + mboot = Mock(read_memory=Mock(return_value=b"\xff" * 4096)) + assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is True + + +def test_is_blank_false_for_occupied_flash(): + data = b"\xff" * 100 + b"\xd1" + b"\xff" * (4096 - 101) # один "чужой" байт + mboot = Mock(read_memory=Mock(return_value=data)) + assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False + + +def test_is_blank_false_when_read_returns_empty(): + """read_memory возвращает b"" на отказ команды (см. spsdk docstring) — + не должно ложно читаться как «всё 0xFF» (vacuous truth на пустой bytes).""" + mboot = Mock(read_memory=Mock(return_value=b"")) + assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False + + +def test_is_blank_false_when_read_returns_none(): + mboot = Mock(read_memory=Mock(return_value=None)) + assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False + + +def test_flash_uses_region_erase_when_blank(monkeypatch, events, tmp_path): + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 15) + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + ctx = _mock_mcuboot_ctx(monkeypatch) # read_memory → блок по умолчанию + + fb.flash(hab_bin, progress_cb=_collector(events)) + + ctx.flash_erase_region.assert_called_once() + ctx.flash_erase_all.assert_not_called() + + +def test_flash_uses_full_erase_when_not_blank(monkeypatch, events, tmp_path): + """Р16: непустой Flash (например, от прошивки большего размера раньше) → + полный erase вместо узкого region — иначе хвост за границей нового + образа остаётся нетронутым.""" + hab_bin = tmp_path / "fw_hab.bin" + hab_bin.write_bytes(b"\xd1" + b"\x00" * 15) + + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) + ctx = _mock_mcuboot_ctx( + monkeypatch, read_memory=b"\xff" * 100 + b"\xab" + b"\xff" * 3995 + ) + + fb.flash(hab_bin, progress_cb=_collector(events)) + + ctx.flash_erase_all.assert_called_once_with(mem_id=fb.FLEXSPI_MEMORY_ID) + ctx.flash_erase_region.assert_not_called() + + # ─── erase_chip() ───────────────────────────────────────────────────────── @@ -412,6 +516,33 @@ def test_erase_chip_fails(monkeypatch, events): assert "done" not in _phases(events) +def test_erase_chip_connection_error_device_gone(monkeypatch, events): + """Р17: SPSDKConnectionError из McuBoot-команды + плата пропала → + ConnectionLostError (не safety-net «Непредвиденная ошибка»).""" + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock())) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False)) + ctx = _mock_mcuboot_ctx(monkeypatch) + ctx.flash_erase_all.side_effect = fb.SPSDKConnectionError("bus gone") + + with pytest.raises(fb.ConnectionLostError) as ei: + fb.erase_chip(progress_cb=_collector(events)) + assert ei.value.connection_lost is True + + +def test_erase_chip_connection_error_device_present(monkeypatch, events): + """Р17: тот же SPSDKConnectionError, но плата всё ещё в режиме Flashloader + → честная ошибка, не обрыв.""" + monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock())) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + ctx = _mock_mcuboot_ctx(monkeypatch) + ctx.flash_erase_all.side_effect = fb.SPSDKConnectionError("глюк одной команды") + + with pytest.raises(fb.FlashBackendError) as ei: + fb.erase_chip(progress_cb=_collector(events)) + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False + + # ─── build_custom_hab() — НЕ мокается, реальный HabImage ───────────────── @@ -616,10 +747,12 @@ def test_run_flash_cmd_bails_immediately_if_device_gone(monkeypatch): sleep_mock.assert_not_called() -def test_load_flashloader_timeout_is_connection_lost(monkeypatch): - """SPSDKTimeoutError из SDP-обмена → ConnectionLostError (обёртка 4a).""" +def test_load_flashloader_timeout_device_gone_is_connection_lost(monkeypatch): + """SPSDKTimeoutError из SDP-обмена + плата реально пропала → ConnectionLostError.""" monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[])) - monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()])) + # Первый scan — проверка ДО входа в SDP-сессию (плата есть), второй — + # _sdp_still_present() ПОСЛЕ сбоя (плата пропала), см. Р17. + monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(side_effect=[[Mock()], []])) monkeypatch.setattr( fb, "flashloader_bin_path", @@ -639,13 +772,43 @@ def test_load_flashloader_timeout_is_connection_lost(monkeypatch): fb.load_flashloader() -def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_path): - """Гейт 4a #1: SPSDKTimeoutError из write_memory → ConnectionLostError, - а не safety-net «Непредвиденная ошибка».""" +def test_load_flashloader_timeout_device_present_is_plain_error(monkeypatch): + """Р17: тот же SPSDKTimeoutError, но плата всё ещё на шине в SDP-режиме + (напр. одиночный сбой HID-записи) → честная ошибка, НЕ обрыв сессии — + раньше это всегда маскировалось под ConnectionLostError.""" + monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[])) + monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()])) + monkeypatch.setattr( + fb, + "flashloader_bin_path", + Mock( + return_value=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.FlashBackendError) as ei: + fb.load_flashloader() + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False + + +def test_flash_write_memory_timeout_device_gone_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())) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False)) ctx = _mock_mcuboot_ctx(monkeypatch) ctx.write_memory.side_effect = fb.SPSDKTimeoutError() @@ -656,6 +819,25 @@ def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_ assert "done" not in _phases(events) +def test_flash_write_memory_timeout_device_present_is_plain_error( + monkeypatch, events, tmp_path +): + """Р17: тот же SPSDKTimeoutError, но плата всё ещё в режиме Flashloader + → честная ошибка, остаёмся на FlashScreen (не обрыв сессии).""" + 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())) + monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True)) + ctx = _mock_mcuboot_ctx(monkeypatch) + ctx.write_memory.side_effect = fb.SPSDKTimeoutError() + + with pytest.raises(fb.FlashBackendError) as ei: + fb.flash(hab_bin, progress_cb=_collector(events)) + assert not isinstance(ei.value, fb.ConnectionLostError) + assert ei.value.connection_lost is False + + def test_flash_write_false_device_gone_is_connection_lost( monkeypatch, events, tmp_path ):