# 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 здесь не существует). Урок: десятичные
|
||||
значения в чужих логах пересчитывать инструментом, не «на глаз» — misread
|
||||
одного слова стоил трёх раундов на живом железе, включая один регресс.
|
||||
|
||||
Р16 (пожелание с производства/сервиса, не баг): часть плат попадает на стенд
|
||||
с уже занятой W25Q — flash_erase_region в flash() стирает только под новый
|
||||
образ, хвост за границей erase_size (от прошивки БОЛЬШЕГО размера раньше)
|
||||
остаётся нетронутым. Фикс: перед erase — быстрый _is_blank() на первый
|
||||
BLANK_CHECK_SIZE=0x1000 байт (стёртый NOR физически 0xFF); если не пусто —
|
||||
flash_erase_all вместо flash_erase_region. Один сектор — компромисс: FCB+
|
||||
вектора всегда пишутся первыми, поэтому непустой чип почти всегда «виден»
|
||||
уже там, а читать больше — это доп. round-trip на КАЖДУЮ прошивку, не
|
||||
только на проблемные платы. erase_chip() не тронут — «Очистить память» и
|
||||
так всегда полный.
|
||||
|
||||
Р17 (полевой отчёт: "USB-соединение потеряно при загрузке Flashloader:
|
||||
SDP: Connection issue -> SPSDK: Invalid size of written bytes has been
|
||||
detected: -1 != 1025" — плата физически осталась на шине, WaitingScreen
|
||||
почти сразу переоткрывал FlashScreen, статус не успевал прочитаться).
|
||||
_fail_command() (вариант B, Р10/Р13) проверяет присутствие платы перед тем,
|
||||
как объявить обрыв — но ЭТО применялось только к пути «команда вернула
|
||||
False». Все 4 места, ловящие _CONNECTION_LOST_EXCEPTIONS как ИСКЛЮЧЕНИЕ
|
||||
(load_flashloader() — SDP-фаза, flash() ram_only-ветка, flash() основной
|
||||
McuBoot-блок, erase_chip()), безусловно считали любой SPSDKConnectionError/
|
||||
SPSDKTimeoutError обрывом сессии, не проверяя, жива ли плата. Одиночный сбой
|
||||
HID-записи (bytes_written=-1, см. пример выше) — это не обязательно обрыв:
|
||||
плата может остаться на шине. Фикс: все 4 места теперь проверяют присутствие
|
||||
перед классификацией — _sdp_still_present() (новый, симметричный
|
||||
_flashloader_still_present, для фаз ДО перехода на Flashloader — SDP-часть
|
||||
load_flashloader() и ram_only) или _flashloader_still_present() (для фаз
|
||||
ПОСЛЕ — основной McuBoot-блок flash()/erase_chip()). Плата на месте →
|
||||
FlashBackendError (честная ошибка, остаёмся на экране — тот же путь, что
|
||||
уже был для варианта B); плата пропала → ConnectionLostError, как раньше.
|
||||
Ничего в flasher.py/screens не менялось — Flasher._run_flash_op() уже читал
|
||||
exc.connection_lost с любого FlashBackendError, UI уже умел оставаться на
|
||||
FlashScreen при connection_lost=False (Гейт 4a, вариант 2) — эта правка
|
||||
просто перестала СИСТЕМАТИЧЕСКИ обходить эту логику для exception-путей.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -243,6 +277,18 @@ FLASHLOADER_WAIT_TIMEOUT_S = 10.0
|
|||
CMD_RETRY_ATTEMPTS = 3
|
||||
CMD_RETRY_DELAY_S = 0.5
|
||||
|
||||
# Р16 — обнаружение непустого Flash перед прошивкой (пожелание с производства/
|
||||
# сервиса: часть плат попадает на стенд с уже занятой W25Q). flash_erase_region
|
||||
# стирает только под новый образ — если на чипе раньше лежал образ БОЛЬШЕГО
|
||||
# размера, хвост за границей erase_size остаётся нетронутым. Один быстрый
|
||||
# read_memory на BLANK_CHECK_SIZE байт (стёртый NOR физически 0xFF, надёжный
|
||||
# и однозначный сигнал) решает, стирать узкую область или чип целиком.
|
||||
# Размер — один сектор (граница erase-гранулярности): FCB+вектора всегда
|
||||
# пишутся первыми, поэтому непустой чип почти всегда «виден» уже в первом
|
||||
# секторе — читать больше ради надёжности не нужно, но и не бесплатно (это
|
||||
# доп. round-trip'ы на КАЖДУЮ прошивку, не только на проблемные платы).
|
||||
BLANK_CHECK_SIZE = 0x1000
|
||||
|
||||
_HAB_OPTIONS_TEMPLATE = [
|
||||
"options:",
|
||||
" flags: 0x00",
|
||||
|
|
@ -351,6 +397,23 @@ def _flashloader_still_present() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _sdp_still_present() -> bool:
|
||||
"""Быстрая проверка присутствия платы в режиме SDP (Р17).
|
||||
|
||||
Симметрично _flashloader_still_present(), но для ДРУГОЙ фазы: SDP-часть
|
||||
load_flashloader() и ram_only-ветка flash() падают ДО перехода на
|
||||
Flashloader — устройство физически ещё не успело туда спрыгнуть, здесь
|
||||
корректно проверять именно SDP (не наоборот, как исторически было в Р13).
|
||||
|
||||
Любая ошибка самой проверки трактуется как «устройства нет» — та же
|
||||
логика, что и в _flashloader_still_present().
|
||||
"""
|
||||
try:
|
||||
return detect_sdp()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _fail_command(mboot: McuBoot, message: str) -> None:
|
||||
"""Живая команда spsdk вернула False — переклассификация по варианту B (Р10).
|
||||
|
||||
|
|
@ -401,6 +464,22 @@ def _run_flash_cmd(mboot: McuBoot, description: str, cmd: Callable[[], bool]) ->
|
|||
_fail_command(mboot, f"{description} вернул False после {attempt} попыт(ок)")
|
||||
|
||||
|
||||
def _is_blank(mboot: McuBoot, address: int, length: int) -> bool:
|
||||
"""True если [address, address+length) полностью стёрт (Р16).
|
||||
|
||||
Стёртый NOR физически 0xFF на уровне бит — однозначный сигнал, в отличие
|
||||
от догадок по контрольным суммам/сигнатурам. Не оборачивается в
|
||||
_run_flash_cmd: это не команда с состоянием «выполнена/не выполнена», а
|
||||
просто чтение — если оно не удалось (data пустой или None, оба falsy),
|
||||
считаем «не пусто» и уходим в полный erase — безопасный дефолт, если не
|
||||
смогли определить реальное состояние чипа.
|
||||
"""
|
||||
data = mboot.read_memory(address, length)
|
||||
if not data:
|
||||
return False
|
||||
return all(b == 0xFF for b in data)
|
||||
|
||||
|
||||
def _emit(
|
||||
progress_cb: Optional[ProgressCallback], phase: str, percent: int, message: str
|
||||
) -> None:
|
||||
|
|
@ -479,6 +558,12 @@ def load_flashloader(
|
|||
sdp.write_file(FLASHLOADER_LOAD_ADDR, data)
|
||||
sdp.jump_and_run(FLASHLOADER_LOAD_ADDR)
|
||||
except _CONNECTION_LOST_EXCEPTIONS as exc:
|
||||
if _sdp_still_present():
|
||||
# Р17: одиночный сбой HID-записи (напр. bytes_written=-1), плата
|
||||
# физически осталась на шине — честная ошибка, не обрыв сессии.
|
||||
raise FlashBackendError(
|
||||
f"Ошибка при загрузке Flashloader (плата на месте): {exc}"
|
||||
) from exc
|
||||
raise ConnectionLostError(
|
||||
f"USB-соединение потеряно при загрузке Flashloader: {exc}"
|
||||
) from exc
|
||||
|
|
@ -579,6 +664,10 @@ def flash(
|
|||
sdp.write_file(addr, hab_bin.read_bytes())
|
||||
sdp.jump_and_run(addr)
|
||||
except _CONNECTION_LOST_EXCEPTIONS as exc:
|
||||
if _sdp_still_present(): # Р17, см. load_flashloader()
|
||||
raise FlashBackendError(
|
||||
f"Ошибка при RAM-загрузке (плата на месте): {exc}"
|
||||
) from exc
|
||||
raise ConnectionLostError(
|
||||
f"USB-соединение потеряно при RAM-загрузке: {exc}"
|
||||
) from exc
|
||||
|
|
@ -600,17 +689,34 @@ def flash(
|
|||
_emit(progress_cb, "configure", 0, "Конфигурация FlexSPI NOR")
|
||||
configure_flexspi(mboot)
|
||||
|
||||
_emit(
|
||||
progress_cb,
|
||||
"erase",
|
||||
0,
|
||||
f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт",
|
||||
)
|
||||
_run_flash_cmd(
|
||||
mboot,
|
||||
"flash_erase_region",
|
||||
lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0),
|
||||
)
|
||||
_emit(progress_cb, "erase", 0, "Проверка состояния Flash")
|
||||
if _is_blank(mboot, FLASH_BASE, BLANK_CHECK_SIZE):
|
||||
_emit(
|
||||
progress_cb,
|
||||
"erase",
|
||||
0,
|
||||
f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт",
|
||||
)
|
||||
_run_flash_cmd(
|
||||
mboot,
|
||||
"flash_erase_region",
|
||||
lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0),
|
||||
)
|
||||
else:
|
||||
# Р16: на чипе уже есть данные (возможно, от прошивки БОЛЬШЕГО
|
||||
# размера) — узкий erase_region оставил бы хвост за границей
|
||||
# нового образа нетронутым. Стираем чип целиком.
|
||||
_emit(
|
||||
progress_cb,
|
||||
"erase",
|
||||
0,
|
||||
"Обнаружены данные во Flash — полная очистка (~30с)",
|
||||
)
|
||||
_run_flash_cmd(
|
||||
mboot,
|
||||
"flash_erase_all",
|
||||
lambda: mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID),
|
||||
)
|
||||
|
||||
_emit(progress_cb, "fcb", 0, "Запись FCB")
|
||||
if fcb_path is not None:
|
||||
|
|
@ -636,6 +742,10 @@ def flash(
|
|||
_emit(progress_cb, "reset", 0, "Reset")
|
||||
mboot.reset(reopen=False)
|
||||
except _CONNECTION_LOST_EXCEPTIONS as exc:
|
||||
if _flashloader_still_present(): # Р17, см. load_flashloader()
|
||||
raise FlashBackendError(
|
||||
f"Ошибка во время прошивки (плата на месте): {exc}"
|
||||
) from exc
|
||||
raise ConnectionLostError(
|
||||
f"USB-соединение потеряно во время прошивки: {exc}"
|
||||
) from exc
|
||||
|
|
@ -669,6 +779,10 @@ def erase_chip(progress_cb: Optional[ProgressCallback] = None) -> None:
|
|||
_emit(progress_cb, "reset", 0, "Reset")
|
||||
mboot.reset(reopen=False)
|
||||
except _CONNECTION_LOST_EXCEPTIONS as exc:
|
||||
if _flashloader_still_present(): # Р17, см. load_flashloader()
|
||||
raise FlashBackendError(
|
||||
f"Ошибка во время chip erase (плата на месте): {exc}"
|
||||
) from exc
|
||||
raise ConnectionLostError(
|
||||
f"USB-соединение потеряно во время chip erase: {exc}"
|
||||
) from exc
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ flash.py — экран прошивки (режим A).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -36,6 +37,14 @@ from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Пожелание с производства/сервиса: после успешной прошивки Production/Custom
|
||||
# переход на WaitingScreen происходил мгновенно — оператор не успевал прочитать
|
||||
# статус в #flash-log (firmware_test в этой паузе не нуждается — там уже есть
|
||||
# 40с в PostFlashScreen, см. app.py::_on_flash_done). Кнопки остаются
|
||||
# заблокированными на время паузы, чтобы не запустить новую операцию поверх
|
||||
# уходящего экрана.
|
||||
_RESULT_HOLD_S = 3.0
|
||||
|
||||
|
||||
class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||
"""
|
||||
|
|
@ -248,10 +257,16 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
|||
fcb_variant=preset.fcb_variant,
|
||||
progress_cb=self._on_progress,
|
||||
)
|
||||
self._set_busy(False)
|
||||
self._finish_progress(result.ok)
|
||||
self._log("✅ Готово" if result.ok else "❌ Ошибка")
|
||||
|
||||
if result.ok and target != FlashTarget.FIRMWARE_TEST:
|
||||
# Держим #flash-log на экране, пока оператор не прочитает статус —
|
||||
# без этого экран уходил на WaitingScreen в тот же тик, что и лог.
|
||||
await asyncio.sleep(_RESULT_HOLD_S)
|
||||
|
||||
self._set_busy(False)
|
||||
|
||||
if result.ok:
|
||||
self.post_message(
|
||||
self.FlashDone(success=True, target=target, preset=preset)
|
||||
|
|
@ -358,6 +373,17 @@ class FlashScreen(Screen, ConnectionWatcherMixin):
|
|||
|
||||
def _log(self, msg: str) -> None:
|
||||
try:
|
||||
self.query_one("#flash-log", Log).write_line(msg)
|
||||
log = self.query_one("#flash-log", Log)
|
||||
log.write_line(msg)
|
||||
# Textual 8.x, Log.write_lines(): is_vertical_scroll_end читается
|
||||
# ДО добавления новой строки, не после (см. _log.py в textual).
|
||||
# Несколько write_line() подряд быстрее, чем успевает осесть
|
||||
# layout (именно так летят события load_flashloader/configure/
|
||||
# erase-decision из фонового потока), — снэпшот «были внизу»
|
||||
# устаревает, и auto_scroll=True на самом Log молча не срабатывает
|
||||
# для этих строк. Фаза "write" (медленнее, с троттлингом) успевает
|
||||
# осесть между вызовами — там штатно и без этого. Принудительный
|
||||
# scroll_end() обходит устаревший снэпшот целиком, а не чинит его.
|
||||
log.scroll_end(animate=False, immediate=True)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -285,7 +285,19 @@ FlashBackendError (connection_lost: bool = False)
|
|||
`SPSDKError`, но **не** `SPSDKConnectionError`; read-фаза после write может
|
||||
отдать голый таймаут вместо connection error). Ловятся кортежем на всех
|
||||
точках отказа: `load_flashloader`, `flash` (основная + `ram_only` ветки),
|
||||
`erase_chip`.
|
||||
`erase_chip`. **Р17:** до фикса эти 4 места безусловно поднимали
|
||||
`ConnectionLostError` на любое такое исключение, не проверяя, жива ли
|
||||
плата (в отличие от варианта B ниже, который проверяет) — одиночный сбой
|
||||
HID-записи (`bytes_written=-1`, реальный полевой пример: плата осталась на
|
||||
шине, `WaitingScreen` почти сразу переоткрывал `FlashScreen`, оператор не
|
||||
успевал прочитать причину) маскировался под полноценный обрыв сессии.
|
||||
Теперь все 4 места сначала проверяют присутствие — `_sdp_still_present()`
|
||||
для фаз ДО перехода на Flashloader (SDP-часть `load_flashloader()`,
|
||||
`ram_only`) или `_flashloader_still_present()` для фаз после (основной
|
||||
`McuBoot`-блок `flash()`/`erase_chip()`) — и при «плата на месте» поднимают
|
||||
обычный `FlashBackendError` вместо `ConnectionLostError`, что автоматически
|
||||
заводит их в тот же путь «остаёмся на FlashScreen», что и вариант B ниже —
|
||||
без изменений в `flasher.py`/UI.
|
||||
- **Вариант B для команд, возвращающих `False` без исключения** (Р10,
|
||||
`_fail_command()`): `flash_erase_region`/`flash_erase_all`/`write_memory`
|
||||
иногда просто возвращают `False` вместо исключения (`McuBoot(iface)`
|
||||
|
|
@ -755,6 +767,18 @@ runtime-зависимостей `boot_art.py` не добавляет). Есл
|
|||
"естественной" (auto) шириной — центрируется именно через `Center()`.
|
||||
Важно не путать эти два случая (`#waiting-version` — первый случай,
|
||||
`#post-flash-title`/`#post-flash-instruction` — второй).
|
||||
- **`Log(auto_scroll=True)` не гарантирует автопрокрутку на каждой строке.**
|
||||
`Log.write_lines()` (`textual/widgets/_log.py`) читает
|
||||
`self.is_vertical_scroll_end` **до** добавления новой строки в буфер, не
|
||||
после, и скроллит только если снэпшот был `True`. Несколько `write_line()`
|
||||
подряд быстрее, чем успевает осесть layout между вызовами (характерно для
|
||||
событий `load_flashloader`/`configure`/`erase`-решения, летящих из
|
||||
фонового потока почти одновременно) — снэпшот устаревает, и часть строк
|
||||
проскакивает без скролла, хотя `auto_scroll=True` стоит. Медленные,
|
||||
троттлированные события (`write`-фаза) успевают осесть между вызовами —
|
||||
там штатно. Обходится принудительным `log.scroll_end(animate=False,
|
||||
immediate=True)` сразу после каждого `write_line()` в вызывающем коде
|
||||
(`FlashScreen._log()`) — не чинит `is_vertical_scroll_end`, а обходит его.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "service-tui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "TUI сервисного инженера для диагностики платы MIMXRT1052"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -240,12 +240,15 @@ def test_write_fcb_explicit_write_fails(monkeypatch, tmp_path):
|
|||
def _mock_mcuboot_ctx(monkeypatch, **method_returns):
|
||||
"""Подменяет fb.McuBoot на context-manager мок с заданными return_value
|
||||
у fill_memory/configure_memory/flash_erase_region/write_memory (все True
|
||||
по умолчанию, кроме явно переопределённых)."""
|
||||
по умолчанию, кроме явно переопределённых). read_memory по умолчанию
|
||||
возвращает «пустой чип» (Р16) — существующие happy-path тесты ожидают
|
||||
flash_erase_region, не flash_erase_all."""
|
||||
defaults = dict(
|
||||
configure_memory=True,
|
||||
flash_erase_region=True,
|
||||
flash_erase_all=True,
|
||||
write_memory=True,
|
||||
read_memory=b"\xff" * fb.BLANK_CHECK_SIZE,
|
||||
)
|
||||
defaults.update(method_returns)
|
||||
|
||||
|
|
@ -295,6 +298,41 @@ def test_flash_ram_only_no_sdp(monkeypatch, events, tmp_path):
|
|||
fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events))
|
||||
|
||||
|
||||
def test_flash_ram_only_connection_error_device_gone(monkeypatch, events, tmp_path):
|
||||
"""Р17: SPSDKConnectionError в ram_only + плата пропала → ConnectionLostError."""
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1")
|
||||
|
||||
# Первый scan — обнаружение SDP-устройства, второй — _sdp_still_present()
|
||||
# после сбоя.
|
||||
monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(side_effect=[[Mock()], []]))
|
||||
mock_sdp_ctx = MagicMock()
|
||||
mock_sdp_ctx.__enter__.return_value = mock_sdp_ctx
|
||||
mock_sdp_ctx.write_file.side_effect = fb.SPSDKConnectionError("bus gone")
|
||||
monkeypatch.setattr(fb, "SDP", Mock(return_value=mock_sdp_ctx))
|
||||
|
||||
with pytest.raises(fb.ConnectionLostError):
|
||||
fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events))
|
||||
|
||||
|
||||
def test_flash_ram_only_connection_error_device_present(monkeypatch, events, tmp_path):
|
||||
"""Р17: тот же SPSDKConnectionError, но плата всё ещё в SDP-режиме →
|
||||
честная ошибка, не обрыв."""
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1")
|
||||
|
||||
monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()]))
|
||||
mock_sdp_ctx = MagicMock()
|
||||
mock_sdp_ctx.__enter__.return_value = mock_sdp_ctx
|
||||
mock_sdp_ctx.write_file.side_effect = fb.SPSDKConnectionError("глюк одной записи")
|
||||
monkeypatch.setattr(fb, "SDP", Mock(return_value=mock_sdp_ctx))
|
||||
|
||||
with pytest.raises(fb.FlashBackendError) as ei:
|
||||
fb.flash(hab_bin, ram_only=True, progress_cb=_collector(events))
|
||||
assert not isinstance(ei.value, fb.ConnectionLostError)
|
||||
assert ei.value.connection_lost is False
|
||||
|
||||
|
||||
def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path):
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1" + b"\x00" * 127)
|
||||
|
|
@ -318,7 +356,16 @@ def test_flash_happy_path_auto_fcb(monkeypatch, events, tmp_path):
|
|||
assert "progress_callback" in wkwargs
|
||||
|
||||
ctx.reset.assert_called_once_with(reopen=False)
|
||||
assert _phases(events) == ["configure", "erase", "fcb", "write", "reset", "done"]
|
||||
# Р16: два "erase"-события — проверка блочности + собственно стирание.
|
||||
assert _phases(events) == [
|
||||
"configure",
|
||||
"erase",
|
||||
"erase",
|
||||
"fcb",
|
||||
"write",
|
||||
"reset",
|
||||
"done",
|
||||
]
|
||||
assert events[-1].percent == 100
|
||||
|
||||
|
||||
|
|
@ -382,6 +429,63 @@ def test_flash_progress_callback_reports_bytes(monkeypatch, events, tmp_path):
|
|||
assert write_events[-1].percent == 100
|
||||
|
||||
|
||||
# ─── Р16: детект непустого Flash → полный erase вместо region ────────────
|
||||
|
||||
|
||||
def test_is_blank_true_for_erased_flash():
|
||||
mboot = Mock(read_memory=Mock(return_value=b"\xff" * 4096))
|
||||
assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is True
|
||||
|
||||
|
||||
def test_is_blank_false_for_occupied_flash():
|
||||
data = b"\xff" * 100 + b"\xd1" + b"\xff" * (4096 - 101) # один "чужой" байт
|
||||
mboot = Mock(read_memory=Mock(return_value=data))
|
||||
assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False
|
||||
|
||||
|
||||
def test_is_blank_false_when_read_returns_empty():
|
||||
"""read_memory возвращает b"" на отказ команды (см. spsdk docstring) —
|
||||
не должно ложно читаться как «всё 0xFF» (vacuous truth на пустой bytes)."""
|
||||
mboot = Mock(read_memory=Mock(return_value=b""))
|
||||
assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False
|
||||
|
||||
|
||||
def test_is_blank_false_when_read_returns_none():
|
||||
mboot = Mock(read_memory=Mock(return_value=None))
|
||||
assert fb._is_blank(mboot, fb.FLASH_BASE, 4096) is False
|
||||
|
||||
|
||||
def test_flash_uses_region_erase_when_blank(monkeypatch, events, tmp_path):
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1" + b"\x00" * 15)
|
||||
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
|
||||
ctx = _mock_mcuboot_ctx(monkeypatch) # read_memory → блок по умолчанию
|
||||
|
||||
fb.flash(hab_bin, progress_cb=_collector(events))
|
||||
|
||||
ctx.flash_erase_region.assert_called_once()
|
||||
ctx.flash_erase_all.assert_not_called()
|
||||
|
||||
|
||||
def test_flash_uses_full_erase_when_not_blank(monkeypatch, events, tmp_path):
|
||||
"""Р16: непустой Flash (например, от прошивки большего размера раньше) →
|
||||
полный erase вместо узкого region — иначе хвост за границей нового
|
||||
образа остаётся нетронутым."""
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1" + b"\x00" * 15)
|
||||
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
|
||||
ctx = _mock_mcuboot_ctx(
|
||||
monkeypatch, read_memory=b"\xff" * 100 + b"\xab" + b"\xff" * 3995
|
||||
)
|
||||
|
||||
fb.flash(hab_bin, progress_cb=_collector(events))
|
||||
|
||||
ctx.flash_erase_all.assert_called_once_with(mem_id=fb.FLEXSPI_MEMORY_ID)
|
||||
ctx.flash_erase_region.assert_not_called()
|
||||
|
||||
|
||||
# ─── erase_chip() ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -412,6 +516,33 @@ def test_erase_chip_fails(monkeypatch, events):
|
|||
assert "done" not in _phases(events)
|
||||
|
||||
|
||||
def test_erase_chip_connection_error_device_gone(monkeypatch, events):
|
||||
"""Р17: SPSDKConnectionError из McuBoot-команды + плата пропала →
|
||||
ConnectionLostError (не safety-net «Непредвиденная ошибка»)."""
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock()))
|
||||
monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False))
|
||||
ctx = _mock_mcuboot_ctx(monkeypatch)
|
||||
ctx.flash_erase_all.side_effect = fb.SPSDKConnectionError("bus gone")
|
||||
|
||||
with pytest.raises(fb.ConnectionLostError) as ei:
|
||||
fb.erase_chip(progress_cb=_collector(events))
|
||||
assert ei.value.connection_lost is True
|
||||
|
||||
|
||||
def test_erase_chip_connection_error_device_present(monkeypatch, events):
|
||||
"""Р17: тот же SPSDKConnectionError, но плата всё ещё в режиме Flashloader
|
||||
→ честная ошибка, не обрыв."""
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=MagicMock()))
|
||||
monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True))
|
||||
ctx = _mock_mcuboot_ctx(monkeypatch)
|
||||
ctx.flash_erase_all.side_effect = fb.SPSDKConnectionError("глюк одной команды")
|
||||
|
||||
with pytest.raises(fb.FlashBackendError) as ei:
|
||||
fb.erase_chip(progress_cb=_collector(events))
|
||||
assert not isinstance(ei.value, fb.ConnectionLostError)
|
||||
assert ei.value.connection_lost is False
|
||||
|
||||
|
||||
# ─── build_custom_hab() — НЕ мокается, реальный HabImage ─────────────────
|
||||
|
||||
|
||||
|
|
@ -616,10 +747,12 @@ def test_run_flash_cmd_bails_immediately_if_device_gone(monkeypatch):
|
|||
sleep_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_load_flashloader_timeout_is_connection_lost(monkeypatch):
|
||||
"""SPSDKTimeoutError из SDP-обмена → ConnectionLostError (обёртка 4a)."""
|
||||
def test_load_flashloader_timeout_device_gone_is_connection_lost(monkeypatch):
|
||||
"""SPSDKTimeoutError из SDP-обмена + плата реально пропала → ConnectionLostError."""
|
||||
monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[]))
|
||||
monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()]))
|
||||
# Первый scan — проверка ДО входа в SDP-сессию (плата есть), второй —
|
||||
# _sdp_still_present() ПОСЛЕ сбоя (плата пропала), см. Р17.
|
||||
monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(side_effect=[[Mock()], []]))
|
||||
monkeypatch.setattr(
|
||||
fb,
|
||||
"flashloader_bin_path",
|
||||
|
|
@ -639,13 +772,43 @@ def test_load_flashloader_timeout_is_connection_lost(monkeypatch):
|
|||
fb.load_flashloader()
|
||||
|
||||
|
||||
def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_path):
|
||||
"""Гейт 4a #1: SPSDKTimeoutError из write_memory → ConnectionLostError,
|
||||
а не safety-net «Непредвиденная ошибка»."""
|
||||
def test_load_flashloader_timeout_device_present_is_plain_error(monkeypatch):
|
||||
"""Р17: тот же SPSDKTimeoutError, но плата всё ещё на шине в SDP-режиме
|
||||
(напр. одиночный сбой HID-записи) → честная ошибка, НЕ обрыв сессии —
|
||||
раньше это всегда маскировалось под ConnectionLostError."""
|
||||
monkeypatch.setattr(fb.MbootUSBInterface, "scan", Mock(return_value=[]))
|
||||
monkeypatch.setattr(fb.SdpUSBInterface, "scan", Mock(return_value=[Mock()]))
|
||||
monkeypatch.setattr(
|
||||
fb,
|
||||
"flashloader_bin_path",
|
||||
Mock(
|
||||
return_value=Mock(
|
||||
exists=Mock(return_value=True),
|
||||
read_bytes=Mock(return_value=b"\x00" * 16),
|
||||
)
|
||||
),
|
||||
)
|
||||
sdp_ctx = MagicMock()
|
||||
sdp_ctx.__enter__.return_value = sdp_ctx
|
||||
sdp_ctx.write_file.side_effect = fb.SPSDKTimeoutError()
|
||||
monkeypatch.setattr(fb, "SDP", Mock(return_value=sdp_ctx))
|
||||
|
||||
with pytest.raises(fb.FlashBackendError) as ei:
|
||||
fb.load_flashloader()
|
||||
assert not isinstance(ei.value, fb.ConnectionLostError)
|
||||
assert ei.value.connection_lost is False
|
||||
|
||||
|
||||
def test_flash_write_memory_timeout_device_gone_is_connection_lost(
|
||||
monkeypatch, events, tmp_path
|
||||
):
|
||||
"""Гейт 4a #1: SPSDKTimeoutError из write_memory + плата пропала →
|
||||
ConnectionLostError, а не safety-net «Непредвиденная ошибка»."""
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1" + b"\x00" * 63)
|
||||
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
|
||||
monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=False))
|
||||
ctx = _mock_mcuboot_ctx(monkeypatch)
|
||||
ctx.write_memory.side_effect = fb.SPSDKTimeoutError()
|
||||
|
||||
|
|
@ -656,6 +819,25 @@ def test_flash_write_memory_timeout_is_connection_lost(monkeypatch, events, tmp_
|
|||
assert "done" not in _phases(events)
|
||||
|
||||
|
||||
def test_flash_write_memory_timeout_device_present_is_plain_error(
|
||||
monkeypatch, events, tmp_path
|
||||
):
|
||||
"""Р17: тот же SPSDKTimeoutError, но плата всё ещё в режиме Flashloader
|
||||
→ честная ошибка, остаёмся на FlashScreen (не обрыв сессии)."""
|
||||
hab_bin = tmp_path / "fw_hab.bin"
|
||||
hab_bin.write_bytes(b"\xd1" + b"\x00" * 63)
|
||||
|
||||
monkeypatch.setattr(fb, "load_flashloader", Mock(return_value=Mock()))
|
||||
monkeypatch.setattr(fb, "detect_flashloader", Mock(return_value=True))
|
||||
ctx = _mock_mcuboot_ctx(monkeypatch)
|
||||
ctx.write_memory.side_effect = fb.SPSDKTimeoutError()
|
||||
|
||||
with pytest.raises(fb.FlashBackendError) as ei:
|
||||
fb.flash(hab_bin, progress_cb=_collector(events))
|
||||
assert not isinstance(ei.value, fb.ConnectionLostError)
|
||||
assert ei.value.connection_lost is False
|
||||
|
||||
|
||||
def test_flash_write_false_device_gone_is_connection_lost(
|
||||
monkeypatch, events, tmp_path
|
||||
):
|
||||
|
|
|
|||
Loading…
Reference in a new issue