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