# service_tui: 0.2.1 small UI fixes

This commit is contained in:
Dmitry Akimov 2026-07-13 14:55:27 +03:00
parent 1d9055628a
commit d9fb813be7
5 changed files with 369 additions and 23 deletions

View file

@ -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,17 +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,
_run_flash_cmd( f"Стирание 0x{FLASH_BASE:08X} + {erase_size} байт",
mboot, )
"flash_erase_region", _run_flash_cmd(
lambda: mboot.flash_erase_region(FLASH_BASE, erase_size, mem_id=0), 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:
@ -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

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,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`, а обходит его.
--- ---

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

@ -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
): ):