# service_tui: 0.2.1 small UI fixes
This commit is contained in:
parent
1d9055628a
commit
d9fb813be7
5 changed files with 369 additions and 23 deletions
|
|
@ -125,6 +125,40 @@ QE=0 (Winbond выпускает варианты с заводским QE=0 и
|
||||||
регистр, не массив — Cumulative Write здесь не существует). Урок: десятичные
|
регистр, не массив — Cumulative Write здесь не существует). Урок: десятичные
|
||||||
значения в чужих логах пересчитывать инструментом, не «на глаз» — misread
|
значения в чужих логах пересчитывать инструментом, не «на глаз» — 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
|
||||||
|
|
@ -243,6 +277,18 @@ FLASHLOADER_WAIT_TIMEOUT_S = 10.0
|
||||||
CMD_RETRY_ATTEMPTS = 3
|
CMD_RETRY_ATTEMPTS = 3
|
||||||
CMD_RETRY_DELAY_S = 0.5
|
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",
|
||||||
|
|
@ -351,6 +397,23 @@ def _flashloader_still_present() -> bool:
|
||||||
return False
|
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:
|
def _fail_command(mboot: McuBoot, message: str) -> None:
|
||||||
"""Живая команда spsdk вернула False — переклассификация по варианту B (Р10).
|
"""Живая команда 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} попыт(ок)")
|
_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(
|
||||||
progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str
|
progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -479,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
|
||||||
|
|
@ -579,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
|
||||||
|
|
@ -600,6 +689,8 @@ def flash(
|
||||||
_emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR")
|
_emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR")
|
||||||
configure_flexspi(mboot)
|
configure_flexspi(mboot)
|
||||||
|
|
||||||
|
_emit(progress_cb, "erase", 0, "Проверка состояния Flash")
|
||||||
|
if _is_blank(mboot, FLASH_BASE, BLANK_CHECK_SIZE):
|
||||||
_emit(
|
_emit(
|
||||||
progress_cb,
|
progress_cb,
|
||||||
"erase",
|
"erase",
|
||||||
|
|
@ -611,6 +702,21 @@ def flash(
|
||||||
"flash_erase_region",
|
"flash_erase_region",
|
||||||
lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0),
|
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:
|
||||||
|
|
@ -636,6 +742,10 @@ def flash(
|
||||||
_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
|
||||||
|
|
@ -669,6 +779,10 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
|
||||||
_emit(progress_cb, "reset", 0, "Reset")
|
_emit(progress_cb, "reset", 0, "Reset")
|
||||||
mboot.reset(reopen=False)
|
mboot.reset(reopen=False)
|
||||||
except _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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,19 @@ 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` вместо исключения (`McuBoot(iface)`
|
иногда просто возвращают `False` вместо исключения (`McuBoot(iface)`
|
||||||
|
|
@ -755,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`, а обходит его.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -240,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)
|
||||||
|
|
||||||
|
|
@ -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))
|
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)
|
||||||
|
|
@ -318,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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -382,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() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -412,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 ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -616,10 +747,12 @@ def test_run_flash_cmd_bails_immediately_if_device_gone(monkeypatch):
|
||||||
sleep_mock.assert_not_called()
|
sleep_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_load_flashloader_timeout_is_connection_lost(monkeypatch):
|
def test_load_flashloader_timeout_device_gone_is_connection_lost(monkeypatch):
|
||||||
"""SPSDKTimeoutError из SDP-обмена → ConnectionLostError (обёртка 4a)."""
|
"""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",
|
||||||
|
|
@ -639,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()
|
||||||
|
|
||||||
|
|
@ -656,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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue