Merge branch 'service-tui-fixes' into dev

This commit is contained in:
Dmitry Akimov 2026-07-13 16:48:00 +03:00
commit cc6f29b8f4
8 changed files with 865 additions and 87 deletions

View file

@ -64,7 +64,7 @@ just host::flash-production # bootloader release + app release (с подт
flowchart TD flowchart TD
A["Плата в SDP-режиме\n1FC9:0130"] --> B["sdphost\nзагрузить ivt_flashloader.bin\nв RAM 0x20001C00"] A["Плата в SDP-режиме\n1FC9:0130"] --> B["sdphost\nзагрузить ivt_flashloader.bin\nв RAM 0x20001C00"]
B --> C["sdphost jump-address\nFlashloader поднимается\nкак 15A2:0073"] 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"] D --> E["flash-erase-region 0x60000000"]
E --> F["configure-memory 0xF000000F\nзапись FCB в 0x60000000"] E --> F["configure-memory 0xF000000F\nзапись FCB в 0x60000000"]
F --> G["write-memory 0x60001000\nHAB-образ"] F --> G["write-memory 0x60001000\nHAB-образ"]

View file

@ -181,7 +181,7 @@ BootROM в режиме SDP умеет только писать в RAM и пр
Flashloader запущен Flashloader запущен
│ blhost -u 0x15A2,0x0073 │ 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 ├─ configure-memory 9 0x2000 ← Flashloader пишет FCB в Flash
├─ flash-erase-region 0x60000000 … ├─ flash-erase-region 0x60000000 …
├─ write-memory 0x60002000 firmware_hab.bin ← HAB образ ├─ write-memory 0x60002000 firmware_hab.bin ← HAB образ

View file

@ -62,11 +62,19 @@ SDP_USB = _usb("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130")
BLHOST_USB = _usb("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073") 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[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_ADDR = "0x2000"
FLEXSPI_OPTION_VALUE = "0xC0000007" FLEXSPI_OPTION_VALUE = "0xC0000207"
FLEXSPI_MEMORY_ID = "9" # FlexSPI NOR memory interface ID FLEXSPI_MEMORY_ID = "9" # FlexSPI NOR memory interface ID
# Option word для записи FCB: tag=0xF → Write FCB command # Option word для записи FCB: tag=0xF → Write FCB command

View file

@ -21,6 +21,144 @@ Zero Textual/asyncio импортов — модуль полностью син
закрывается в finally на любом исходе (защита от утечки HID-хэндла в закрывается в finally на любом исходе (защита от утечки HID-хэндла в
редком окне «wait_for_flashloader вернул интерфейс USB выдернут редком окне «wait_for_flashloader вернул интерфейс USB выдернут
McuBoot.__enter__ упал»). 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
одного слова стоил трёх раундов на живом железе, включая один регресс.
Р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 from __future__ import annotations
@ -109,13 +247,48 @@ HAB_OFFSET = 0x1000
FLASHLOADER_LOAD_ADDR = 0x20001C00 FLASHLOADER_LOAD_ADDR = 0x20001C00
FLEXSPI_OPTION_ADDR = 0x2000 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_FCB_VALUE = 0xF000000F # tag=0xF → Write FCB command (auto-config)
FLEXSPI_MEMORY_ID = 9 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 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
# Р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 = [ _HAB_OPTIONS_TEMPLATE = [
"options:", "options:",
" flags: 0x00", " flags: 0x00",
@ -198,33 +371,113 @@ def detect_cdc() -> bool:
return resolve_serial_port(UsbId(_CDC_VID, _CDC_PID)) is not None 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). """Быстрая проверка «плата ещё на шине» для 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). «плату выдернули» (согласовано, RELEASE_ROADMAP.md §B).
""" """
try: try:
return detect_sdp() return detect_flashloader()
except Exception: # noqa: BLE001 — см. docstring: любой сбой ⇒ считаем обрывом except Exception: # noqa: BLE001 — см. docstring: любой сбой ⇒ считаем обрывом
return False return False
def _fail_command(message: str) -> None: 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). """Живая команда spsdk вернула False — переклассификация по варианту B (Р10).
Если устройство пропало с шины обрыв (ConnectionLostError), иначе Если устройство пропало с шины обрыв (ConnectionLostError), иначе
честная ошибка операции (FlashBackendError с прежним текстом). detect_sdp() честная ошибка операции (FlashBackendError). detect_flashloader()
выполняется ТОЛЬКО здесь, в error-пути; happy path не затрагивается. выполняется ТОЛЬКО здесь, в error-пути; happy path не затрагивается.
mboot.status_string добавляется к тексту ошибки во всех случаях (Р13)
настоящий код статуса spsdk (например «NoResponse» при хостовом
read-таймауте) отличим от кода ошибки, реально сообщённого устройством.
:raises ConnectionLostError: устройство исчезло с шины после сбоя команды. :raises ConnectionLostError: устройство исчезло с шины после сбоя команды.
:raises FlashBackendError: устройство на месте ошибка самой операции. :raises FlashBackendError: устройство на месте ошибка самой операции.
""" """
if not _sdp_still_present(): full_message = f"{message} (status: {mboot.status_string})"
raise ConnectionLostError(f"{message} (устройство пропало с шины)") if not _flashloader_still_present():
raise FlashBackendError(message) 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 _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( def _emit(
@ -305,6 +558,12 @@ def load_flashloader(
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 _CONNECTION_LOST_EXCEPTIONS as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
if _sdp_still_present():
# Р17: одиночный сбой HID-записи (напр. bytes_written=-1), плата
# физически осталась на шине — честная ошибка, не обрыв сессии.
raise FlashBackendError(
f"Ошибка при загрузке Flashloader (плата на месте): {exc}"
) from exc
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно при загрузке Flashloader: {exc}" f"USB-соединение потеряно при загрузке Flashloader: {exc}"
) from exc ) from exc
@ -318,11 +577,28 @@ def load_flashloader(
def configure_flexspi(mboot: McuBoot) -> None: 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) mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE)
ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) mboot.fill_memory(FLEXSPI_OPTION_ADDR + 4, 4, 0)
if not ok: _run_flash_cmd(
_fail_command("configure_memory (FlexSPI init) вернул False") mboot,
"configure_memory (FlexSPI init)",
lambda: mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID),
)
def write_fcb_auto(mboot: McuBoot) -> None: def write_fcb_auto(mboot: McuBoot) -> None:
@ -331,9 +607,11 @@ def write_fcb_auto(mboot: McuBoot) -> None:
Надёжно проверен только для W25Q128 см. docstring оригинала. Надёжно проверен только для W25Q128 см. docstring оригинала.
""" """
mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_FCB_VALUE) mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_FCB_VALUE)
ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) _run_flash_cmd(
if not ok: mboot,
_fail_command("configure_memory (FCB write) вернул False") "configure_memory (FCB write)",
lambda: mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID),
)
def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None: def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None:
@ -345,9 +623,11 @@ def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None:
if not fcb_path.exists(): if not fcb_path.exists():
raise FlashBackendError(f"FCB-файл не найден: {fcb_path}") raise FlashBackendError(f"FCB-файл не найден: {fcb_path}")
data = fcb_path.read_bytes() data = fcb_path.read_bytes()
ok = mboot.write_memory(FLASH_BASE, data, mem_id=0) _run_flash_cmd(
if not ok: mboot,
_fail_command(f"write_memory(FCB {fcb_path.name}) вернул False") f"write_memory(FCB {fcb_path.name})",
lambda: mboot.write_memory(FLASH_BASE, data, mem_id=0),
)
# ─── Прошивка / RAM-load / erase ──────────────────────────────────────────── # ─── Прошивка / RAM-load / erase ────────────────────────────────────────────
@ -384,6 +664,10 @@ def flash(
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 _CONNECTION_LOST_EXCEPTIONS as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
if _sdp_still_present(): # Р17, см. load_flashloader()
raise FlashBackendError(
f"Ошибка при RAM-загрузке (плата на месте): {exc}"
) from exc
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно при RAM-загрузке: {exc}" f"USB-соединение потеряно при RAM-загрузке: {exc}"
) from exc ) from exc
@ -391,6 +675,7 @@ def flash(
return return
iface = load_flashloader(progress_cb) iface = load_flashloader(progress_cb)
iface.device.timeout = MCUBOOT_CMD_TIMEOUT_MS # см. Р13 — до первой команды
write_addr = FLASH_BASE + HAB_OFFSET write_addr = FLASH_BASE + HAB_OFFSET
hab_size = hab_bin.stat().st_size hab_size = hab_bin.stat().st_size
@ -404,15 +689,34 @@ def flash(
_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, "Проверка состояния Flash")
progress_cb, if _is_blank(mboot, FLASH_BASE, BLANK_CHECK_SIZE):
"erase", _emit(
0, progress_cb,
f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт", "erase",
) 0,
ok = mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0) f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт",
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),
)
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") _emit(progress_cb, "fcb", 0, "Запись FCB")
if fcb_path is not None: if fcb_path is not None:
@ -427,15 +731,21 @@ def flash(
_emit(progress_cb, "write", percent, f"{current}/{total} байт") _emit(progress_cb, "write", percent, f"{current}/{total} байт")
data = hab_bin.read_bytes() data = hab_bin.read_bytes()
ok = mboot.write_memory( _run_flash_cmd(
write_addr, data, mem_id=0, progress_callback=_on_progress 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") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) mboot.reset(reopen=False)
except _CONNECTION_LOST_EXCEPTIONS as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
if _flashloader_still_present(): # Р17, см. load_flashloader()
raise FlashBackendError(
f"Ошибка во время прошивки (плата на месте): {exc}"
) from exc
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно во время прошивки: {exc}" f"USB-соединение потеряно во время прошивки: {exc}"
) from exc ) from exc
@ -452,6 +762,7 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
:raises ConnectionLostError: обрыв USB посреди chip erase. :raises ConnectionLostError: обрыв USB посреди chip erase.
""" """
iface = load_flashloader(progress_cb) iface = load_flashloader(progress_cb)
iface.device.timeout = MCUBOOT_CMD_TIMEOUT_MS # см. Р13 — уже для configure_flexspi
try: try:
with McuBoot(iface) as mboot: with McuBoot(iface) as mboot:
@ -459,14 +770,19 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
configure_flexspi(mboot) configure_flexspi(mboot)
_emit(progress_cb, "erase", 0, "Полная очистка Flash (~30с)") _emit(progress_cb, "erase", 0, "Полная очистка Flash (~30с)")
iface.device.timeout = ERASE_ALL_TIMEOUT_MS # см. Фазу 0, ⚠В2 _run_flash_cmd(
ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID) mboot,
if not ok: "flash_erase_all",
_fail_command("flash_erase_all вернул False") lambda: mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID),
)
_emit(progress_cb, "reset", 0, "Reset") _emit(progress_cb, "reset", 0, "Reset")
mboot.reset(reopen=False) mboot.reset(reopen=False)
except _CONNECTION_LOST_EXCEPTIONS as exc: except _CONNECTION_LOST_EXCEPTIONS as exc:
if _flashloader_still_present(): # Р17, см. load_flashloader()
raise FlashBackendError(
f"Ошибка во время chip erase (плата на месте): {exc}"
) from exc
raise ConnectionLostError( raise ConnectionLostError(
f"USB-соединение потеряно во время chip erase: {exc}" f"USB-соединение потеряно во время chip erase: {exc}"
) from exc ) from exc

View file

@ -7,6 +7,7 @@ flash.py — экран прошивки (режим A).
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -36,6 +37,14 @@ from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
logger = logging.getLogger(__name__) 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): class FlashScreen(Screen, ConnectionWatcherMixin):
""" """
@ -248,10 +257,16 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
fcb_variant=preset.fcb_variant, fcb_variant=preset.fcb_variant,
progress_cb=self._on_progress, progress_cb=self._on_progress,
) )
self._set_busy(False)
self._finish_progress(result.ok) self._finish_progress(result.ok)
self._log("✅ Готово" if result.ok else "❌ Ошибка") 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: if result.ok:
self.post_message( self.post_message(
self.FlashDone(success=True, target=target, preset=preset) self.FlashDone(success=True, target=target, preset=preset)
@ -358,6 +373,17 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
def _log(self, msg: str) -> None: def _log(self, msg: str) -> None:
try: 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: except NoMatches:
pass pass

View file

@ -285,16 +285,45 @@ FlashBackendError (connection_lost: bool = False)
`SPSDKError`, но **не** `SPSDKConnectionError`; read-фаза после write может `SPSDKError`, но **не** `SPSDKConnectionError`; read-фаза после write может
отдать голый таймаут вместо connection error). Ловятся кортежем на всех отдать голый таймаут вместо connection error). Ловятся кортежем на всех
точках отказа: `load_flashloader`, `flash` (основная + `ram_only` ветки), точках отказа: `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, - **Вариант B для команд, возвращающих `False` без исключения** (Р10,
`_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory` `_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory`
иногда просто возвращают `False` вместо исключения. В этом случае иногда просто возвращают `False` вместо исключения (`McuBoot(iface)`
`_fail_command()` выполняет быстрый `_sdp_still_present()` (обёрнутый в создаётся с `cmd_exception=False` — дефолт spsdk, статус оседает в
`try/except` — любая ошибка самой проверки трактуется как «устройства `status_code`, исключения нет). В этом случае `_fail_command()` выполняет
нет», т.к. шина к этому моменту уже нестабильна): устройство пропало → быстрый `_flashloader_still_present()` (обёрнутый в `try/except` — любая
`ConnectionLostError`, устройство на месте → обычный `FlashBackendError` с ошибка самой проверки трактуется как «устройства нет», т.к. шина к этому
текстом ошибки операции. Проверка добавляется **только в error-путь**, на моменту уже нестабильна): устройство пропало → `ConnectionLostError`,
happy path не влияет. устройство на месте → обычный `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 использует
502048с на те же команды. На части плат в поле реальный ответ не
укладывался в 2с → тихий `NO_RESPONSE``False` → неверно
классифицировался как «устройство пропало с шины». Фикс: единый
`MCUBOOT_CMD_TIMEOUT_MS` выставляется сразу после `load_flashloader()`,
до первой команды сессии (не только перед `flash_erase_all`, как было
раньше); `_fail_command()` проверяет `detect_flashloader()`, не SDP.
- **`Flasher._run_flash_op()`** (`flasher.py`) конвертирует - **`Flasher._run_flash_op()`** (`flasher.py`) конвертирует
`FlashBackendError` обратно в `FlashResult(ok=False, `FlashBackendError` обратно в `FlashResult(ok=False,
connection_lost=exc.connection_lost)` + событие `FlashProgress(phase="error")`. connection_lost=exc.connection_lost)` + событие `FlashProgress(phase="error")`.
@ -352,8 +381,9 @@ AppFrame {
Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются Штатные HAB-образы (`firmware_test`/`bootloader`/`app`) собираются
`nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 — `nxpimage` заранее (`just build::hab-*`) и всегда идут на плату с W25Q128 —
для них auto-config Flashloader (`configure-memory 0xC0000007` → для них auto-config Flashloader (`configure-memory 0xC0000207` →
`0xF000000F`, см. `HOW_TO_FLASH.md`) достаточен. Для сторонних/легаси `0xF000000F`, см. `HOW_TO_FLASH.md`; до Р15 было `0xC0000007` — без
установки QE-бита, см. §8.4) достаточен. Для сторонних/легаси
бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader бинарников (старые платы, W25Q256/512) это не так: auto-config Flashloader
не документирован как надёжный для 4-байтной адресации, а сами бинарники не документирован как надёжный для 4-байтной адресации, а сами бинарники
приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат, приходят «сырыми» (код + таблица векторов, без FCB/IVT/DCD — тот же формат,
@ -449,12 +479,72 @@ def write_fcb_explicit(mboot: McuBoot, fcb_path: Path) -> None:
`fcb_path` в `flash_backend.flash()` — путь без него не тронут: штатная `fcb_path` в `flash_backend.flash()` — путь без него не тронут: штатная
прошивка идёт через auto-config `write_fcb_auto`, как и раньше. прошивка идёт через auto-config `write_fcb_auto`, как и раньше.
Таймаут для `flash_erase_all` (chip erase) увеличен до `ERASE_ALL_TIMEOUT_MS Таймаут HID-команд (`iface.device.timeout`) поднят до `MCUBOOT_CMD_TIMEOUT_MS
= 200_000` мс (эквивалент `blhost -t 200000`): W25Q512 стирается заметно = 200_000` мс (эквивалент `blhost -t 200000`) на всю Flashloader-сессию
дольше W25Q128, дефолтного таймаута McuBoot не хватало. целиком — `configure_flexspi()`, `flash_erase_region`/`flash_erase_all`,
`flash_erase_region` (стирание пары секторов под FCB+HAB при обычной `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`) ### 8.5 UI (`flash.py`)
@ -677,6 +767,18 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
"естественной" (auto) шириной — центрируется именно через `Center()`. "естественной" (auto) шириной — центрируется именно через `Center()`.
Важно не путать эти два случая (`#waiting-version` — первый случай, Важно не путать эти два случая (`#waiting-version` — первый случай,
`#post-flash-title`/`#post-flash-instruction` — второй). `#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`, а обходит его.
--- ---

View file

@ -1,6 +1,6 @@
[project] [project]
name = "service-tui" name = "service-tui"
version = "0.2.0" version = "0.2.1"
description = "TUI сервисного инженера для диагностики платы MIMXRT1052" description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View file

@ -67,6 +67,16 @@ def test_detect_cdc_not_found(monkeypatch):
assert fb.detect_cdc() is False 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 ──────────────────────────────────────────────── # ─── wait_for_flashloader ────────────────────────────────────────────────
@ -157,17 +167,30 @@ def test_load_flashloader_happy_path(monkeypatch, events):
def test_configure_flexspi_ok(): def test_configure_flexspi_ok():
mboot = Mock(configure_memory=Mock(return_value=True)) mboot = Mock(configure_memory=Mock(return_value=True))
fb.configure_flexspi(mboot) fb.configure_flexspi(mboot)
mboot.fill_memory.assert_called_once_with( # Р15: option0 (0xC0000207 — включая QE-бит) + явное обнуление option1,
fb.FLEXSPI_OPTION_ADDR, 4, fb.FLEXSPI_OPTION_VALUE # зеркало последовательности 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( mboot.configure_memory.assert_called_once_with(
fb.FLEXSPI_OPTION_ADDR, fb.FLEXSPI_MEMORY_ID 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): def test_configure_flexspi_fail(monkeypatch):
# Плата на месте → честная ошибка операции, НЕ обрыв (вариант B, Фаза 4a). # Плата на месте → честная ошибка операции, НЕ обрыв (вариант 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)) mboot = Mock(configure_memory=Mock(return_value=False))
with pytest.raises(fb.FlashBackendError) as ei: with pytest.raises(fb.FlashBackendError) as ei:
fb.configure_flexspi(mboot) 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): 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 = tmp_path / "w25q128_fdcb.bin"
fcb.write_bytes(b"\xab" * 512) fcb.write_bytes(b"\xab" * 512)
mboot = Mock(write_memory=Mock(return_value=False)) mboot = Mock(write_memory=Mock(return_value=False))
@ -216,12 +240,15 @@ def test_write_fcb_explicit_write_fails(monkeypatch, tmp_path):
def _mock_mcuboot_ctx(monkeypatch, **method_returns): def _mock_mcuboot_ctx(monkeypatch, **method_returns):
"""Подменяет fb.McuBoot на context-manager мок с заданными return_value """Подменяет fb.McuBoot на context-manager мок с заданными return_value
у fill_memory/configure_memory/flash_erase_region/write_memory (все True у fill_memory/configure_memory/flash_erase_region/write_memory (все True
по умолчанию, кроме явно переопределённых).""" по умолчанию, кроме явно переопределённых). read_memory по умолчанию
возвращает «пустой чип» (Р16) существующие happy-path тесты ожидают
flash_erase_region, не flash_erase_all."""
defaults = dict( defaults = dict(
configure_memory=True, configure_memory=True,
flash_erase_region=True, flash_erase_region=True,
flash_erase_all=True, flash_erase_all=True,
write_memory=True, write_memory=True,
read_memory=b"\xff" * fb.BLANK_CHECK_SIZE,
) )
defaults.update(method_returns) defaults.update(method_returns)
@ -271,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)) 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): def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path):
hab_bin = tmp_path / "fw_hab.bin" hab_bin = tmp_path / "fw_hab.bin"
hab_bin.write_bytes(b"\xd1" + b"\x00" * 127) hab_bin.write_bytes(b"\xd1" + b"\x00" * 127)
@ -294,7 +356,16 @@ def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path):
assert "progress_callback" in wkwargs assert "progress_callback" in wkwargs
ctx.reset.assert_called_once_with(reopen=False) 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 assert events[-1].percent == 100
@ -321,8 +392,9 @@ def test_flash_write_memory_fails(monkeypatch, events, tmp_path):
hab_bin.write_bytes(b"\xd1") hab_bin.write_bytes(b"\xd1")
monkeypatch.setattr( 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())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
_mock_mcuboot_ctx(monkeypatch, write_memory=False) _mock_mcuboot_ctx(monkeypatch, write_memory=False)
@ -357,6 +429,63 @@ def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path):
assert write_events[-1].percent == 100 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() ───────────────────────────────────────────────────────── # ─── erase_chip() ─────────────────────────────────────────────────────────
@ -367,7 +496,7 @@ def test_erase_chip_happy_path(monkeypatch, events):
fb.erase_chip(progress_cb=_collector(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.flash_erase_all.assert_called_once_with(mem_id=fb.FLEXSPI_MEMORY_ID)
ctx.reset.assert_called_once_with(reopen=False) ctx.reset.assert_called_once_with(reopen=False)
assert _phases(events) == ["configure", "erase", "reset", "done"] assert _phases(events) == ["configure", "erase", "reset", "done"]
@ -375,8 +504,9 @@ def test_erase_chip_happy_path(monkeypatch, events):
def test_erase_chip_fails(monkeypatch, events): def test_erase_chip_fails(monkeypatch, events):
monkeypatch.setattr( 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())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock()))
_mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) _mock_mcuboot_ctx(monkeypatch, flash_erase_all=False)
@ -386,6 +516,33 @@ def test_erase_chip_fails(monkeypatch, events):
assert "done" not in _phases(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 ───────────────── # ─── build_custom_hab() — НЕ мокается, реальный HabImage ─────────────────
@ -511,31 +668,91 @@ def test_connection_lost_tuple_covers_timeout():
assert not issubclass(fb.SPSDKTimeoutError, fb.SPSDKConnectionError) assert not issubclass(fb.SPSDKTimeoutError, fb.SPSDKConnectionError)
def test_sdp_still_present_swallows_check_error(monkeypatch): def test_flashloader_still_present_swallows_check_error(monkeypatch):
"""§B: ошибка самой проверки detect_sdp() трактуется как «устройства нет».""" """§B: ошибка самой проверки detect_flashloader() трактуется как «устройства нет»."""
monkeypatch.setattr(fb, "detect_sdp", Mock(side_effect=RuntimeError("bus gone"))) monkeypatch.setattr(
assert fb._sdp_still_present() is False 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): 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: 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 ei.value.connection_lost is True
assert "NoResponse" in str(ei.value)
def test_fail_command_device_present_is_plain_error(monkeypatch): 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: 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 not isinstance(ei.value, fb.ConnectionLostError)
assert ei.value.connection_lost is False assert ei.value.connection_lost is False
assert "kStatus_FlashCommandFailure" in str(ei.value)
def test_load_flashloader_timeout_is_connection_lost(monkeypatch): # ─── Р14: retry маргинального контакта (_run_flash_cmd) ──────────────────
"""SPSDKTimeoutError из SDP-обмена → ConnectionLostError (обёртка 4a)."""
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_device_gone_is_connection_lost(monkeypatch):
"""SPSDKTimeoutError из SDP-обмена + плата реально пропала → ConnectionLostError."""
monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[])) 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( monkeypatch.setattr(
fb, fb,
"flashloader_bin_path", "flashloader_bin_path",
@ -555,13 +772,43 @@ def test_load_flashloader_timeout_is_connection_lost(monkeypatch):
fb.load_flashloader() fb.load_flashloader()
def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_path): def test_load_flashloader_timeout_device_present_is_plain_error(monkeypatch):
"""Гейт 4a #1: SPSDKTimeoutError из write_memory → ConnectionLostError, """Р17: тот же SPSDKTimeoutError, но плата всё ещё на шине в SDP-режиме
а не safety-net «Непредвиденная ошибка».""" (напр. одиночный сбой 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 = tmp_path / "fw_hab.bin"
hab_bin.write_bytes(b"\xd1" + b"\x00" * 63) hab_bin.write_bytes(b"\xd1" + b"\x00" * 63)
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False))
ctx = _mock_mcuboot_ctx(monkeypatch) ctx = _mock_mcuboot_ctx(monkeypatch)
ctx.write_memory.side_effect = fb.SPSDKTimeoutError() ctx.write_memory.side_effect = fb.SPSDKTimeoutError()
@ -572,6 +819,25 @@ def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_
assert "done" not in _phases(events) 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( def test_flash_write_false_device_gone_is_connection_lost(
monkeypatch, events, tmp_path monkeypatch, events, tmp_path
): ):
@ -581,19 +847,79 @@ def test_flash_write_false_device_gone_is_connection_lost(
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
_mock_mcuboot_ctx(monkeypatch, write_memory=False) _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): with pytest.raises(fb.ConnectionLostError):
fb.flash(hab_bin, progress_cb=_collector(events)) fb.flash(hab_bin, progress_cb=_collector(events))
def test_erase_chip_false_device_gone_is_connection_lost(monkeypatch, 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())) monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock()))
_mock_mcuboot_ctx(monkeypatch, flash_erase_all=False) _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: with pytest.raises(fb.ConnectionLostError) as ei:
fb.erase_chip(progress_cb=_collector(events)) fb.erase_chip(progress_cb=_collector(events))
assert ei.value.connection_lost is True assert ei.value.connection_lost is True
assert "done" not in _phases(events) 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.