From 1801f1beb959d610d31ee3dcd1f91046953117d4 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Thu, 2 Jul 2026 13:50:23 +0300 Subject: [PATCH] # Spike tests: Windows & MacOS spsdk behaviour --- CHANGELOG.md | 80 +++- tools/production/docs/MONOLITH_APP_PLAN.md | 452 +++++++++++++++++++++ tools/production/docs/RELEASE_PLAN.md | 128 ++++++ tools/production/docs/TUI_SESSION_1.md | 121 ------ tools/production/docs/TUI_SESSION_2.md | 163 -------- tools/production/pyproject.toml | 8 +- tools/production/spike/spike_flash.py | 190 +++++++++ tools/production/spike/spike_hab.py | 216 ++++++++++ 8 files changed, 1071 insertions(+), 287 deletions(-) create mode 100644 tools/production/docs/MONOLITH_APP_PLAN.md create mode 100644 tools/production/docs/RELEASE_PLAN.md delete mode 100644 tools/production/docs/TUI_SESSION_1.md delete mode 100644 tools/production/docs/TUI_SESSION_2.md create mode 100644 tools/production/spike/spike_flash.py create mode 100644 tools/production/spike/spike_hab.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb5683..722f13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,84 @@ - Следить за развитием BSP: MQS, RGB, bootloader или `tft_app`. - Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log. +## [Не выпущено] — service-tui: кастомная прошивка нестандартной памяти (после слияния `feature-tui-python`) + +Диапазон: `<заполнить после merge>..<текущий HEAD>` +Сравнение: `<заполнить после merge>` + +> Изменения внесены **поверх** слияния `feature-tui-python → dev` — базовая +> архитектура `service-tui` (экраны, USB/M5-клиенты, оркестратор) приходит +> самим merge-коммитом; здесь только то, что было доработано отдельно после. + +### Кратко + +- `service-tui` теперь умеет прошивать сторонние/легаси бинарники (платы + с W25Q256/512 вместо штатного W25Q128) через USB SDP, с явной записью + FCB вместо ненадёжного для таких чипов auto-config Flashloader. +- Выбор оператора на `FlashScreen` (файл/память/DCD) запоминается на весь + запуск TUI — ускоряет прошивку партии одинаковых плат. +- Документация (`tools/production/README.md`, `tools/production/DEV_ARCH.md`, + `tools/host/README.md`, `docs/HOW_TO_FLASH.md`, `docs/DEV_ARCH.md`) + синхронизирована с фактическим состоянием кода. + +### Добавлено + +- `tools/host/flash_usb.py` — `write_fcb_explicit()` + флаг `--fcb-path`: + буквальная запись 512-байтного FCB-блоба (`write-memory 0x60000000`) + вместо magic option word `0xF000000F`. Штатный `--firmware`-путь + (`firmware_test`/`bootloader`/`app`) не тронут — работает как раньше. +- `tools/production/app/models.py` — `FcbVariant` (`W25Q128` покрывает и + W25Q64, `W25Q512` — и W25Q256) и `FlashPreset` (липкий выбор оператора). +- `tools/production/app/flasher.py` — `_build_custom_hab()`: сборка + HAB-образа на лету через `nxpimage hab export` из «сырого» бинарника + (без FCB/IVT/DCD) в `custom_binaries/`, с опциональным `DCDFilePath`; + стриминг вывода `nxpimage` в UI-лог, а не только в `logger.debug`. + `list_custom_binaries()` + `SERVICE_CUSTOM_BINARIES_DIR` — резолв + директории кастомных бинарей (внешняя, не пакуется в PyInstaller). +- `tools/production/app/screens/flash.py` — `Select` по `custom_binaries/`, + `Select` по `FcbVariant`, `Switch` DCD вместо свободного текстового + `Input`; предзаполнение из `FlashPreset` при создании экрана. +- `tools/production/app/app.py` — `ServiceApp._last_flash_preset`, + прокидывается в новый `FlashScreen` при каждом `DeviceDetected(FLASHING)`. + +### Изменено + +- `tools/host/flash_usb.py`, `erase_chip()` — таймаут `blhost` + `flash-erase-all` увеличен до `-t 200000` (W25Q512 стирается заметно + дольше W25Q128, дефолтного таймаута не хватало). `flash-erase-region` + (обычная прошивка) не тронут — там стирается пара секторов, масштаб иной. +- `tools/production/app/app.tcss` — `#flash-target-group` ограничен по + высоте (`max-height: 18`, свой скролл), `#flash-log` защищён + `min-height: 6` — разросшаяся custom-группа больше не сжимает лог + прошивки до нечитаемого состояния. + +### Документация + +- `tools/production/README.md` — мокап `FlashScreen` под факт (Select/Select/ + Switch), новый workflow «Прошивка стороннего бинарника», `SERVICE_CUSTOM_BINARIES_DIR` + в примере `.env`. +- `tools/production/DEV_ARCH.md` — новый §8 (конвейер кастомной прошивки, + `FlashPreset`, явная запись FCB, обоснование отказа от auto-config для + W25Q256/512 и от полноценного авто-батч-режима прошивки). +- `docs/HOW_TO_FLASH.md` — §1.5, сноска в сравнительной таблице способов + прошивки (FCB «не нужен» верно только для W25Q128). +- `docs/DEV_ARCH.md` — `tools/production/` добавлен в дерево структуры + репозитория (отсутствовал ранее). +- `tools/host/README.md` — актуализирован статус `dcd/*.bin` (`w25q512_fdcb.bin` + теперь используется), указатель на `service-tui` как способ прошивки + нестандартной памяти. + +### Известные ограничения + +- Auto-config Flashloader для W25Q256/512 не проверялся напрямую — решение + писать FCB явно снимает вопрос архитектурно, но не подтверждает и не + опровергает надёжность auto-config как таковую. +- Полноценный режим массового программирования (авто-прошивка по факту + детекта USB, без подтверждения оператора) рассмотрен и отклонён — в + SDP/Flashloader-режиме нет способа прочитать UID платы для идентификации. +- Standalone-упаковка (`PyInstaller`) для этого функционала ещё не + реализована — см. `tools/production/RELEASE_PLAN.md`. + ## [2026-06-29] — Этапы 6г–7: MQS, HIL pytest firmware_test, Provisioning ### Кратко @@ -609,4 +687,4 @@ - Упоминать изменения README только тогда, когда они влияют на onboarding, понимание API, инструкции сборки/тестирования или архитектуру проекта. - Упоминать изменения тестовой инфраструктуры, когда они затрагивают host tests, HIL tests, firmware-side test modules, fixtures, runners, protocol commands или expected coverage. - Упоминать CI changes, когда меняются workflows, local CI tasks, artifacts, triggers, status или self-hosted runner strategy. -- Молчать, если новых коммитов нет или найден только отфильтрованный шум. +- Молчать, если новых коммитов нет или найден только отфильтрованный шум. \ No newline at end of file diff --git a/tools/production/docs/MONOLITH_APP_PLAN.md b/tools/production/docs/MONOLITH_APP_PLAN.md new file mode 100644 index 0000000..b999efa --- /dev/null +++ b/tools/production/docs/MONOLITH_APP_PLAN.md @@ -0,0 +1,452 @@ +# service-tui — миграция на монолит (V4), USB-кроссплатформенность и релиз + +> Единый рабочий документ. Объединяет и заменяет `MONOLITH_PLAN.md` +> и `USB_CROSSPLATFORM.md`; заменяет шаг 3 («Упаковка», вариант B +> с venv) в `RELEASE_PLAN.md`. Шаги 1–2 плана релиза (merge, CHANGELOG) +> выполнены и не затрагиваются; шаги 4–6 переезжают в фазы 5–6. + +Ветка: `feature-tui-monolith` от `dev`. Каждая фаза = коммит(ы) с +зелёным гейтом; откат любой фазы не ломает предыдущие. + +--- + +## 1. Принятые решения (зафиксировано) + +### Р1. Nuitka снят с повестки; упаковка — PyInstaller + +Защита исходников — не требование. PyInstaller — официально +поддержанный NXP путь развёртывания spsdk. Один exe, без +`tools_host/.venv`. + +### Р2. `tools/host/flash_usb.py` НЕ трогаем + +Остаётся dev-CLI для `just host::flash*`, `incoming`, `production`. +TUI получает собственный нативный spsdk-backend. Прецедент закрыт +ранее: `tools/shared/m5_agent.py` сознательно не делался — независимые +клиенты признаны правильным решением, не техдолгом. Бонусы: +мгновенный откат на любой фазе и независимый эталон поведения для +hardware-гейтов. + +### Р3. Соответствие CLI → Python API (проверено по документации spsdk) + +| Сейчас (subprocess) | Станет (in-process) | +| ---------------------------------------------------- | ----------------------------------------------------------------- | +| `sdphost -u ... write-file 0x20001C00 ` | `SDP.write_file(0x20001C00, data)` | +| `sdphost -u ... jump-address 0x20001C00` | `SDP.jump_and_run(0x20001C00)` | +| `blhost -u ... get-property 1 0` (поллинг) | `McuBoot.get_property(PropertyTag.CURRENT_VERSION)` | +| `blhost ... fill-memory 0x2000 4 0xC0000007 word` | `McuBoot.fill_memory(0x2000, 4, 0xC0000007)` | +| `blhost ... configure-memory 9 0x2000` | `McuBoot.configure_memory(0x2000, mem_id=9)` | +| `blhost ... flash-erase-region 0x60000000 0` | `McuBoot.flash_erase_region(0x60000000, size)` | +| `blhost ... write-memory 0` | `McuBoot.write_memory(addr, data)` | +| `blhost -t 200000 ... flash-erase-all 9` | `McuBoot.flash_erase_all(mem_id=9)` + таймаут ⚠В2 | +| `blhost ... reset` | `McuBoot.reset(reopen=False)` | +| `uv run nxpimage hab export -c -o ` | `HabImage` (пакет `spsdk.image.hab`) → `.export()` ⚠В1 | +| Детект SDP/Flashloader | `SdpUSBInterface.scan(...)` / `MbootUSBInterface.scan()` (см. Р7) | + +### Р4. Потоковая модель + +- `flash_backend.py` — чистый синхронный Python, **ноль** импортов + Textual/asyncio; прогресс — синхронный callback. +- Мост поток→loop живёт **внутри `Flasher`** (не в экранах): + `asyncio.to_thread(...)` + `asyncio.run_coroutine_threadsafe()`. +- Публичный API `Flasher` заморожен → `flash.py`/`waiting.py` в фазах + 1–3 не редактируются. Главный контейнер регрессии. +- Worker-поток не трогает виджеты (грабли `self._running`/ + `MessagePump` из DEV_ARCH §13 сюда не заносим). + +### Р5. Отмену операций сознательно НЕ делаем + +Как сейчас: кнопки блокируются `_set_busy`. Блокирующий USB-вызов из +потока корректно не прервать; обрыв кабеля backend обнаружит сам через +исключения spsdk — это и есть заявленный выигрыш V4 вместо +зомби-subprocess. + +### Р6. Data-файлы и временные файлы + +- Единый источник `tools/host/dcd/` (`ivt_flashloader.bin`, `dcd.bin`, + `*_fdcb.bin`) — их использует и нетронутый `flash_usb.py`. TUI + резолвит двухрежимным паттерном (dev: repo-relative; frozen: рядом + с exe через PyInstaller `datas`). Дублей блобов в репо не заводим. +- Временные HAB-файлы — в `tempfile.gettempdir()`; cwd-магия + «temp .yaml в `tools/host/hab/`» умирает вместе с subprocess (в + Python API пути абсолютные). Побочный выигрыш: frozen-бандлу не + нужна записываемая директория внутри себя. + +### Р7. Детект устройств — через spsdk, `pyusb` удаляется ⚠ пересмотр закрытого решения + +Пересматривает `_detect_usb` (pyusb) и вытекающее требование +Zadig/WinUSB из `RELEASE_PLAN.md` шаг 4. **Требует твоего явного +подтверждения** — после него считается принятым. + +Суть: SDP BootROM (`1FC9:0130`) и Flashloader (`15A2:0073`) — это +**HID**-устройства. spsdk общается с ними через libusbsio/hidapi, +которому Zadig не нужен — именно поэтому sdphost/blhost/SPT у NXP +работают на Windows из коробки. WinUSB был нужен только нашему +pyusb-детекту; привязка WinUSB к HID-устройству вдобавок *отбирает* +его у стандартного HID-стека. Требование Zadig — самонаведённое. + +Замена (фаза 1): + +- `detect_sdp()` → `SdpUSBInterface.scan(device_id="0x1FC9:0x0130")`; +- детект Flashloader → `MbootUSBInterface.scan()`; +- `detect_cdc()` → только `serial.tools.list_ports` по VID:PID + (CDC по определению виден как COM-порт; pyusb-ветка ничего не + добавляла). + +Следствия: Zadig исчезает из полевой инструкции целиком; `pyusb` и +`libusb-1.0.dll` уходят из зависимостей/бандла; вместо них в бандл +должны попасть нативные библиотеки libusbsio (гейт фазы 5). + +### Р8. Резолв serial-портов: VID:PID — идентичность, имя порта — рантайм + +Имена портов не переносимы даже в пределах одной ОС (перевоткнул в +другой USB-порт — имя изменилось: `cu.usbmodemXXXX` / `COMn` / +`ttyACMn`). Принцип: + +``` +1. Задан _PORT в окружении → использовать as-is (escape hatch). +2. Иначе list_ports по VID:PID. +3. Одно совпадение → info.device (pyserial открывает и cu.*, и COMn, + включая COM>9, без платформенных приседаний). +4. Ноль → «не найдено» (для TUI — штатное состояние WaitingScreen). +5. Несколько → первое + warning в лог; дизамбигуация по + serial_number — задел на будущее (см. О3). +``` + +Реализация — новый `tools/production/app/usb_ports.py`. В shared не +выносится (прецедент Р2): HIL-стенд стационарный, пиновка портов в +`.env` там осмысленна и остаётся как есть. + +### Р9. Пересмотр `.env` (контекст `tools/production`; HIL-блок не трогается) + +| Переменная | Судьба | +| --------------------------------------------------------------- | -------------------------------------------- | +| `BOOTROM_VID/PID`, `FLASHLOADER_VID/PID`, `SERVICE_CDC_VID/PID` | Остаются (идентичность) | +| `SERVICE_M5_VID/PID` | Добавить (сейчас M5 идентифицируется портом) | +| `HIL_USB_CDC_PORT`, `HIL_M5_PORT` (в контексте TUI) | Необязательный override | + +Принцип: production-TUI запускается на чистой машине **вообще без +`.env`** — все значения имеют fallback-константы в коде (для VID/PID +уже так). `.env` — инструмент разработчика/стенда, не артефакт рядом +с exe. + +--- + +## 2. Матрица «USB-класс × ОС» (справочная база решений Р7/Р8) + +| Устройство | VID:PID | Класс | macOS | Windows 10/11 | Linux | +| --------------------------- | ----------- | ------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| BootROM SDP | `1FC9:0130` | HID | Из коробки (IOHIDFamily) | Из коробки (`hidclass`) | Из коробки; права на `hidraw` (udev) | +| Flashloader | `15A2:0073` | HID | Из коробки | Из коробки | То же | +| firmware_test | `1996:00AD` | CDC ACM | Из коробки, `/dev/cu.usbmodem*` | Из коробки с Win10 (`usbser.sys` по классу), `COMn` | Из коробки, `/dev/ttyACM*`, dialout (рецепт `setup-m5-udev` есть) | +| M5StampPLC | см. О1 | CDC или мост | см. О1 | см. О1 | — | +| `pyusb`/libusb перечисление | — | — | Работает (текущий детект) | **Не видит без WinUSB/libusbK** (задокументировано в RELEASE_PLAN) + нужен `libusb-1.0.dll` в бандле | Работает при правах | + +macOS-нюанс, снимаемый резолвером Р8 автоматически: использовать +`cu.*`, не `tty.*` (callout не ждёт DCD) — `list_ports` на macOS и +так отдаёт `cu.*`. + +--- + +## 3. Открытые вопросы (требуют ответа/измерения до соответствующей фазы) + +**О1. M5StampPLC — чем представляется хосту?** Честно: не знаю — +нативный ESP32-S3 USB CDC (VID Espressif `303A`) или мост +CH9102/CP210x. Снимается за минуту: воткнуть M5, выполнить +`python -m serial.tools.list_ports -v` → VID:PID + serial_number. +Развилка: нативный CDC → драйверы не нужны нигде, Р8 закрывает +вопрос; мост → на изолированной Windows без сети нужен один +вендорский драйвер (честная необходимость уровня ОС, в отличие от +Zadig), пункт в инструкцию сервисника + проверка на гейте 5. +**Измерение — в фазе 0.** + +**О2. Состав `firmware/` в релизе v1** — унаследован из +`RELEASE_PLAN.md`: только `firmware_test` Debug (Release нестабилен)? +`bootloader`+`app` не включаем? Нужен для фазы 5, не раньше. + +**О3. Несколько одинаковых устройств одновременно** — сознательно за +скобками v1: у SDP BootROM различающего серийника нет, UX +WaitingScreen рассчитан на одну плату. Фиксируется в README как +известное ограничение, не как долг. Подтверди, что объём согласован. + +--- + +## Фаза 0 — Спайк / де-риск (без изменений в TUI) + +Цель: подтвердить неизвестные API (⚠В1, ⚠В2, сигнатуры `scan()`) +и совместимость зависимостей — до первой строки боевого кода. + +**Файлы:** `tools/production/pyproject.toml`, `uv.lock`, +`tools/production/spike/` (временная директория, в релиз не идёт). + +1. Добавить `spsdk==3.7.0` в `tools/production`, `uv lock`/`uv sync` — + дерево (156 пакетов в `tools/host/uv.lock`) не должно конфликтовать + с `textual`/`pyserial`. +2. `spike_hab.py`: HAB из «сырого» бинарника через `HabImage` с теми же + опциями, что в `_build_custom_hab()` (`startAddress=0x60000000, + ivtOffset=0x1000, initialLoadSize=0x2000, family=mimxrt1050`; + DCD on/off). — закрывает ⚠В1. +3. `spike_flash.py`: `SdpUSBInterface.scan` → `SDP.write_file` + + `jump_and_run` → поллинг `McuBoot.get_property` → + `configure_memory`; выяснить механизм таймаута ≥200 с для + `flash_erase_all` (эквивалент `blhost -t 200000`). — закрывает ⚠В2 + и сигнатуры Р7. +4. Прогнать `spike_flash.py` на **Windows-машине без Zadig** — + дешёвая ранняя проверка Р7 (детект + HID-транспорт). +5. Измерение О1 (VID:PID/serial M5 через list_ports). + +### Гейт 0 + +- [ ] `uv lock` без конфликтов. +- [ ] **Golden-тест HAB (byte-exact):** образ из `HabImage` побайтно + равен `nxpimage hab export` с тем же конфигом, DCD on/off. + Железо не нужно. Оформить pytest'ом — остаётся навсегда как + регрессия на апгрейды spsdk. +- [ ] Железо: flashloader поднимается через Python API, + `get_property` отвечает. +- [ ] Windows без Zadig: SDP виден, flashloader грузится. +- [ ] Известен способ задать таймаут ≥200 с для erase-all. +- [ ] О1 закрыт (VID:PID зафиксирован, ветка развилки известна). +- [ ] **Стоп-условие В1:** `HabImage` не даёт byte-exact / API + непригоден → HAB остаётся subprocess-вызовом `nxpimage` + in-process; остальной монолит не страдает; фаза 3 сужается. + Решение фиксируется до старта фазы 1. + +--- + +## Фаза 1 — Backend-модуль (синхронное ядро, без UI) + +**Файлы (новые):** `tools/production/app/flash_backend.py`, +`tools/production/app/usb_ports.py`, +`tools/production/tests/test_flash_backend.py`. +**Файлы (правки):** нет — `flasher.py`, `flash.py` не трогаются. + +`flash_backend.py` — прямой перенос логики `flash_usb.py` по таблице Р3: + +- `detect_sdp()/detect_cdc()` — по Р7 (spsdk scan + list_ports), + pyusb-код не переносится; +- `load_flashloader(progress)` — идемпотентно, как сейчас («уже + запущен — пропускаем»), поллинг с тем же 10-секундным лимитом; +- `configure_flexspi()`, `write_fcb()`, `write_fcb_explicit(path)` — + 1:1 с `flash_usb.py`, включая option words `0xC0000007`/`0xF000000F` + и расчёт `erase_size` по 4K-секторам; +- `flash_image(hab_bin, fcb_path|None, progress)`, `erase_chip(progress)`; +- прогресс: `Callable[[FlashProgress], None]`, фазы — честные этапы + конвейера (`flashloader/configure/erase/fcb/write/reset`) вместо + regex-парсинга stdout. Если спайк подтвердил `progress_callback` + у записи — процент внутри `write_memory`, иначе поэтапный + (5 этапов ≈ 20% гранулярность — приемлемо); +- ошибки: доменное `FlashBackendError(phase, cause)`; внутри перехват + `SdpError`/`McuBootError`/`McuBootConnectionError`; таймаут и + «устройство пропало» различимы. + +`usb_ports.py` — резолвер Р8 (`UsbId`, `resolve_serial_port`). + +### Гейт 1 + +- [ ] Unit-тесты (без железа): мок `McuBoot`/`SDP`, сверка + последовательности команд с `flash_usb.py` как эталоном для + flash/erase/fcb-explicit; исключения → `FlashBackendError` + с корректной фазой; резолвер портов (override / одно / + ноль / несколько совпадений). +- [ ] Smoke на железе через mini-CLI (`python -m app.flash_backend`): + прошивка `firmware_test_hab.bin` (Debug), плата грузится, + текущий TUI (subprocess-версия!) видит CDC, `ping→pong`. +- [ ] Chip erase на W25Q512 укладывается в таймаут. + +--- + +## Фаза 2 — Пересадка `Flasher` на backend (async-фасад) + +**Файлы (правки):** `tools/production/app/flasher.py` — переписывается +изнутри при неизменном публичном API. +**Не трогаются:** `flash.py`, `waiting.py`, `app.py`, +`connection_watcher.py`, `models.py`. + +- `flash()/erase_chip()`: вместо `uv run ...` и `_run_cmd` — + `await asyncio.to_thread(backend..., ...)`; loop захватывается до + ухода в поток, прогресс пробрасывается через + `run_coroutine_threadsafe(progress_cb(p), loop)`. +- `detect_sdp()/detect_cdc()/list_custom_binaries()` — делегирование + в backend, сигнатуры и `@staticmethod` прежние. +- PRODUCTION-цепочка (bootloader → app при успехе) остаётся в + `Flasher.flash()`. +- Удаляются: `_run_cmd`, `_run_flash`, `_run_flash_bin`, + `_parse_progress`, `_RE_PERCENT`, `_RE_PHASE`, пути `uv`/скрипта. + `_run_flash_custom`/`_build_custom_hab` пока на subprocess + (мигрируют в фазе 3) — смешанный режим допустим, API этого не видит. +- Резолв `BUILD_DIR` HAB-образов переезжает в backend: dev — + `/build//_hab.bin`, frozen — + `sys.executable.parent / "firmware"` (схема из RELEASE_PLAN §3, + теперь без venv). + +### Гейт 2 + +- [ ] `git diff` подтверждает: `flash.py` не изменён ни на строку. +- [ ] Headless Textual-тест: FlashScreen — прогресс обновляется, + кнопки блокируются/разблокируются, `FlashDone` с корректными + полями. +- [ ] Железо: полный цикл через TUI — firmware_test → + PostFlashScreen → диагностика; PRODUCTION (два образа подряд); + chip erase. Поведение визуально эквивалентно subprocess-версии. +- [ ] `#flash-log` не «зависает» на долгих этапах. + +--- + +## Фаза 3 — Кастомные бинарники: HAB in-process + явный FCB + +**Файлы (правки):** `flash_backend.py` (+`build_custom_hab()`), +`flasher.py` (custom-путь → backend). +**Не трогается:** `flash.py` (UI custom-группы готов). + +- `build_custom_hab(raw_bin, use_dcd)`: конфиг формируется в памяти, + `DCDFilePath` — абсолютным путём через резолвер Р6; временный образ — + в системном tmp. Если сработало стоп-условие В1 — та же сигнатура, + внутри subprocess `nxpimage`; UI разницы не видит. +- `_run_flash_custom`: `build_custom_hab` → `flash_image(hab, + fcb_path=dcd/_fdcb.bin)` — вся цепочка in-process + (`write_fcb_explicit` готов с фазы 1). +- Golden-тест фазы 0 расширяется custom-кейсом (реальный + легаси-бинарник, DCD on/off). + +### Гейт 3 (повторяет чек-лист RELEASE_PLAN шага 4 по custom-пути) + +- [ ] Golden-тест HAB зелёный для custom-кейса. +- [ ] Железо W25Q128: custom, DCD off → грузится. +- [ ] Железо W25Q512: custom → грузится; якорная проверка 4-байтной + адресации (aliasing-методика) в порядке. +- [ ] Chip erase → повторная прошивка → плата живая. +- [ ] «Липкий» `FlashPreset` работает (следующая плата — выбор + подставлен). + +--- + +## Фаза 4 — Нативная обработка отвала USB + зачистка + +**Файлы (правки):** `flash_backend.py`, `flasher.py`; точечно +`flash.py`/`connection_watcher.py` — только если гейт покажет +необходимость (по умолчанию нет). + +- Обрыв посреди операции: `McuBootConnectionError`/таймауты → + `FlashBackendError(..., connection_lost=True)` → `Flasher` возвращает + `False` + финальный `FlashProgress(phase="error")` с + человекочитаемым сообщением. Схема с `ConnectionWatcherMixin` + прежняя: во время `_flashing` watcher приглушён, обрыв репортит сам + backend — то, что раньше делал subprocess, без зомби-процессов. +- Ревизия ресурсов: USB-интерфейсы закрываются в + `finally`/context-manager'ах при любом исходе (утечка HID-хэндла — + классическая причина «device busy» при повторе). +- Зачистка: следов subprocess-эры, `uv`, путей `flash_usb.py`, + `_HAB_DIR`-магии в `tools/production/` не остаётся. + +### Гейт 4 (деструктивные сценарии на железе) + +- [ ] Выдернуть USB во время `write-memory` → ошибка в TUI, возврат + на WaitingScreen, повторная вставка → повторная прошивка + успешна (порт не «занят»). +- [ ] Выдернуть во время chip erase (W25Q512) → то же; выход из + приложения чистый, подвисших потоков нет. +- [ ] Выдернуть в простое на FlashScreen → срабатывает watcher + (регрессия старого пути). +- [ ] `grep -r "flash_usb\|uv run\|subprocess\|usb.core" \ + tools/production/app/` — пусто. + +--- + +## Фаза 5 — Упаковка PyInstaller (замена шага 3 RELEASE_PLAN) + +**Файлы (новые):** `tools/production/service_tui.spec`, рецепты в +`just/ci.just` или `host.just` (по месту; имена задач согласуем +отдельно, не изобретаю). + +``` +service-tui-vX.Y.Z-/ +├── service_tui[.exe] ← PyInstaller, onedir (onefile на Windows +│ замедляет старт распаковкой — не берём) +├── _internal/ ← рантайм PyInstaller +│ └── data/ ← dcd.bin, *_fdcb.bin, ivt_flashloader.bin +│ (datas из tools/host/dcd/), data spsdk +├── firmware/ +│ └── Debug/firmware_test_hab.bin (состав — см. О2) +└── custom_binaries/ ← пустая, создаётся и так +``` + +Ключевые пункты spec: + +- `collect_data_files("spsdk")` (+ при необходимости + `SPSDK_DATA_FOLDER` — документированный NXP механизм для frozen); +- **`collect_dynamic_libs("libusbsio")`** — нативный HID-транспорт + spsdk (следствие Р7); `libusb-1.0.*` в бандле отсутствует; +- `datas`: `tools/host/dcd/{dcd.bin, w25q128_fdcb.bin, + w25q512_fdcb.bin, ivt_flashloader.bin}` → `data/`; +- резолвер путей backend'а: frozen → `data/` рядом с exe; +- версия: `_read_app_version()` (tomllib) — `pyproject.toml` в + `datas`, проверить чтение во frozen. + +### Гейт 5 (замена шага 4 RELEASE_PLAN; Windows + macOS) + +- [ ] Чистая Windows-машина, **без Zadig, без сети, без Python/uv**: + полный полевой цикл — детект SDP → firmware_test → диагностика → + custom (128 и 512) → chip erase. +- [ ] Если О1 = мост: установка одного вендорского драйвера по + инструкции, M5-функции работают. +- [ ] То же на macOS (в ветке «мост» — проверить и там). +- [ ] Версия на WaitingScreen корректна во frozen. +- [ ] Порты резолвятся при перетыкании в другой физический USB-порт + (проверка Р8 на обеих ОС). + +--- + +## Фаза 6 — Документация, CHANGELOG, релиз (шаги 5–6 RELEASE_PLAN) + +**Файлы:** `CHANGELOG.md`; `RELEASE_PLAN.md` (закрыть шаг 3 ссылкой +сюда); `docs/DEV_ARCH.md` (§2 — убрать `subprocess uv run` из +диаграммы, §8.3 — новый конвейер); `HOW_TO_FLASH.md`; `README.md` +`tools/production`; `.env.example` (по Р9). + +- CHANGELOG: монолит (flash_backend, отказ от venv/subprocess), + нативный детект без Zadig, кроссплатформенный резолв портов, + нативная обработка отвала USB, упаковка одним exe. +- Zadig-инструкция в доки **не добавляется** (RELEASE_PLAN планировал + добавить — отменено по Р7); при ветке О1-«мост» — добавляется + инструкция по одному вендорскому драйверу. +- Зафиксировать разделение: `flash_usb.py` — dev-CLI (just-рецепты), + `flash_backend.py` — production-TUI; независимые реализации по + прецеденту M5-клиентов (Р2). +- Golden-тест HAB — обязательный при апгрейде spsdk. +- Ограничение «одна плата на столе» (О3) — в README. +- Тег релиза = версия из `pyproject.toml`. + +### Гейт 6 + +- [ ] Документация синхронизирована (железо подтверждено гейтами 3–5). +- [ ] `just host::flash*`, `incoming`, `production` работают как + раньше — регрессия dev-пути. +- [ ] Релизный артефакт собран из тега; чек-лист гейта 5 повторён на + релизном бинаре. + +--- + +## Сводка рисков и trade-offs + +| Риск / trade-off | Фаза | Митигация / цена | +| --------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------- | +| `HabImage` в 3.7.0 не byte-exact / непригоден | 0 | Стоп-условие В1: HAB остаётся subprocess in-process; монолит не страдает | +| Таймаут `flash_erase_all` (W25Q512) | 0 | Спайк ⚠В2 до боевого кода | +| Сигнатуры `scan()` / поведение HID-скана на Windows | 0 | Спайк на Windows без Zadig | +| Гранулярность прогресса хуже stdout-парсинга | 1–2 | Поэтапный прогресс (5 фаз); честнее текущего — `flash_usb.py` процентов фактически не печатает, бар и сейчас живёт на фазах | +| Утечка USB-хэндла → «device busy» | 4 | Context-managers + деструктивный гейт 4 | +| PyInstaller: data/hooks spsdk, нативные libusbsio | 5 | Документированный NXP путь + `SPSDK_DATA_FOLDER` + `collect_dynamic_libs`; риск смещён на CI, не в поле | +| Textual-грабли при потоках | 2 | Мост изолирован во `Flasher`; экраны не трогаются до фазы 4 | +| Отказ от отмены операций (Р5) | — | Цена: UX как сегодня (ждать до конца/обрыва); выигрыш: нет некорректного прерывания USB-транзакций | +| Первое-из-нескольких при дубликатах устройств (О3) | — | Warning в лог; serial_number-дизамбигуация — задел | + +## Порядок ревью + +По практике проекта — один файл за раз; полные файлы там, где файл +новый или переписывается целиком (`flash_backend.py`, `usb_ports.py`, +`flasher.py`), unified diff — для точечных правок (`pyproject.toml`, +документация, `.env.example`). + +**Старт (после подтверждения Р7 и, желательно, измерения О1):** +фаза 0 — diff `pyproject.toml` + `spike_hab.py` + `spike_flash.py`. \ No newline at end of file diff --git a/tools/production/docs/RELEASE_PLAN.md b/tools/production/docs/RELEASE_PLAN.md new file mode 100644 index 0000000..b99d09d --- /dev/null +++ b/tools/production/docs/RELEASE_PLAN.md @@ -0,0 +1,128 @@ +# service-tui — план первого релиза + +> Не путать с `TUI_PLAN.md` (бэклог фич UI — экспорт результатов в JSON, +> копирование UID) — этот документ про выпуск текущей версии, а не про +> новые возможности. Если в проекте уже есть `PLAN.md`/`TUI_PLAN.md` — +> имеет смысл свести их в один файл; пока создан отдельно, чтобы не +> перезаписать вслепую то, чего я не видел. + +Ветка разработки: `feature-tui-python` → `dev`. Особенностей процесса +(review/CI-гейты) нет. + +--- + +## 1. Слияние `feature-tui-python` → `dev` + +Ничем не заблокировано — документация синхронизирована, полевые прогоны +идут без блокирующих багов. Выполняется отдельно от вырезки релиза (шаг 6). + +**Статус:** готово. + +--- + +## 2. `CHANGELOG.md` + +Запись по всему циклу этого треда: custom-бинарники (`custom_binaries/`, +`FcbVariant`, явная запись FCB вместо auto-config), sticky-выбор +(`FlashPreset`), CSS-фикс переполнения `FlashScreen`, увеличенный таймаут +`flash-erase-all` для W25Q512. + +**Статус:** готово. + +--- + +## 3. Упаковка приложения + +**Решение:** вариант **B** — предсобранный `tools_host/` (venv со spsdk) +кладётся рядом с exe в релизном архиве, без необходимости `uv`/сети на +машине сервисника. + +### Layout архива + +``` +service-tui-vX.Y.Z-/ +├── service_tui[.exe] ← PyInstaller +├── firmware/ +│ └── Debug/firmware_test_hab.bin +├── custom_binaries/ ← пустая, создаётся и так +└── tools_host/ ← предсобранный venv + flash_usb.py, dcd/ + ├── .venv/ + ├── flash_usb.py + ├── dcd/ + └── pyproject.toml / uv.lock +``` + +### Код — `flasher.py` + +Два режима резолва пути/интерпретатора `flash_usb.py`, по аналогии с уже +реализованным резолвом `custom_binaries/`: + +```python +if getattr(sys, "frozen", False): + _HOST_TOOLS_DIR = Path(sys.executable).resolve().parent / "tools_host" + _PYTHON = _HOST_TOOLS_DIR / ".venv" / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + cmd = [str(_PYTHON), str(_HOST_TOOLS_DIR / "flash_usb.py"), ...] +else: + _HOST_TOOLS_DIR = Path(__file__).parents[3] / "tools" / "host" + cmd = ["uv", "run", "--directory", str(_HOST_TOOLS_DIR), "python", "flash_usb.py", ...] +``` + +`BUILD_DIR` (штатные `firmware_test`/`bootloader`/`app`) — та же логика: +frozen-режим по умолчанию резолвит `sys.executable.parent / "firmware"`, +dev-режим — как сейчас (`/build`). + +### Открытые вопросы + +- **Состав `firmware/` в v1** — предполагается только `firmware_test` + **Debug** (Release помечен нестабильным; `firmware_test` — единственный + образ, непосредственно нужный для диагностики). `bootloader`+`app` + (Production) в первый релиз не включены — требует подтверждения. +- **Переносимость venv между машинами** — если в `spsdk` есть нативные + компоненты (не чистый Python), venv, собранный на CI/машине разработчика, + может не завестись на машине сервисника с другой версией ОС/libc. + Требует проверки на шаге 4, а не предположения. + +**Статус:** не начато. + +--- + +## 4. Тестирование на отдельном хосте (Windows) + +**Известный риск (не блокер, но обязательный шаг перед раздачей):** +`pyusb` на Windows не видит устройство без явно привязанного драйвера +(WinUSB/libusbK). Без этого `Flasher.detect_sdp()` молча возвращает `False` +и `WaitingScreen` никогда не поймает плату в SDP-режиме — фолбэк на +`serial.tools.list_ports` не спасает, у SDP нет serial-порта в принципе. + +**Обязательные подготовительные действия на тестовой машине:** +1. Через **Zadig** привязать WinUSB к `1FC9:0130` (BootROM SDP) +2. Через **Zadig** привязать WinUSB к `15A2:0073` (Flashloader) +3. Это нужно занести в инструкцию для сервисника (`README.md`/`HOW_TO_FLASH.md`), + не держать только в голове — иначе на полевом Windows-ноутбуке повторится + та же засада молча. + +**Также проверить на этом шаге:** +- Переносимость `tools_host/.venv` (см. открытый вопрос шага 3) +- Полный цикл: прошивка firmware_test → диагностика → custom-бинарник (128/512) → chip erase + +**Статус:** не начато, ждёт шага 3. + +--- + +## 5. Доработки по необходимости + +Резерв на то, что найдётся на шаге 4. Содержание заранее не известно — +пункт-заглушка, наполняется по факту тестирования. + +--- + +## 6. Первый релиз + +Состав: `service-tui` (упакован по схеме шага 3) + `firmware_test` (Debug, +см. открытый вопрос шага 3). Версия TUI берётся из `pyproject.toml` +(`_read_app_version()`, уже используется на `WaitingScreen`) — тег релиза +предлагается синхронизировать с этим значением. + +**Статус:** не начато, ждёт шагов 1–5. \ No newline at end of file diff --git a/tools/production/docs/TUI_SESSION_1.md b/tools/production/docs/TUI_SESSION_1.md deleted file mode 100644 index a01ae9b..0000000 --- a/tools/production/docs/TUI_SESSION_1.md +++ /dev/null @@ -1,121 +0,0 @@ -# Отчёт: разработка и стабилизация service-tui - -**Период:** один рабочий тред, от architecture-планирования до прод-готовности -**Объект:** `tools/production/` — TUI-приложение для сервисного инженера (диагностика и прошивка платы MIMXRT1052CVJ5B) - ---- - -## 1. Архитектура и первичная разработка (Этап 8) - -Построено с нуля на Python + Textual, монорепо `tools/production/`: - -``` -tools/production/ -├── main.py — точка входа (10 строк) -├── pyproject.toml — textual, pyserial, python-dotenv, pyinstaller, pyusb -└── app/ - ├── app.py — ServiceApp, роутинг экранов - ├── app.tcss — единый файл стилей - ├── models.py — AppMode, TestStatus, FlashTarget, TestInfo, TestResult, SessionState, ConfirmRequest, FlashProgress - ├── firmware_client.py — async USB CDC клиент firmware_test - ├── m5_client.py — async M5StampPLC клиент (HIL) - ├── flasher.py — subprocess-обёртка над tools/host/flash_usb.py - ├── orchestrator.py — маршрутизация confirm_request, обработка progress/timeout - ├── widgets/ - │ └── app_frame.py — общий адаптивный контейнер для всех экранов - └── screens/ - ├── waiting.py — WaitingScreen (USB autodetect) - ├── flash.py — FlashScreen (прошивка/chip erase) - ├── post_flash.py — PostFlashScreen (промпт смены BootMode) - ├── connection_watcher.py — миксин мониторинга обрыва USB - └── diag/ - ├── __init__.py — DiagScreen (координатор) - ├── test_list.py — TestListPanel (чекбоксы тестов) - ├── results.py — ResultsPanel (DataTable результатов) - └── confirm_panel.py — ConfirmPanel (prompt + countdown) -``` - -### Ключевые архитектурные решения -- **Оркестрация confirm_request** по `id`: HIL (opto/CAN) → автоматически через M5, `btn*` → инструкция без JSON-ответа, остальное → оператор с countdown -- **Flasher** не дублирует spsdk-окружение — вызывает `tools/host/flash_usb.py` как subprocess -- **Standalone-сборка** через PyInstaller для сервисников без Python -- **Три режима прошивки**: firmware_test / Production / кастомный бинарь + chip erase - ---- - -## 2. Расширения протокола и инструментария - -| Что | Где | -| ------------------------------------------------------------------------------ | --------------------------------------------------------- | -| `--bin-path`, `--erase-chip` в `flash_usb.py` | `tools/host/flash_usb.py` | -| `get_version` команда + CMake-версионирование (`version.h.in`) | firmware (`protocol.c/h`, `cli.c`, `CMakeLists.txt`) | -| `FIRMWARE_BUILD_TYPE` из `.env` (Debug по умолчанию — Release пока нестабилен) | `flasher.py` | -| Поддержка кириллицы в названиях тестов/промптах | `firmware_client.py`, `m5_client.py` (UTF-8 вместо ASCII) | - ---- - -## 3. Найденные и исправленные баги (хронологически) - -### Линкер и сборка -- **`${PROJECT_SOURCE_DIR}` → `${CMAKE_SOURCE_DIR}`** в `firmware/test/CMakeLists.txt` — после добавления `project(VERSION)` путь к линкер-скрипту стал резолвиться неверно. - -### USB-детект -- На macOS BootROM SDP (`1FC9:0130`) не создаёт serial-порт → невидим через `pyserial.list_ports`. Добавлен **pyusb** как primary метод детекта с fallback на `list_ports`. - -### Textual-специфичные баги -- `Screen.Message` не существует в Textual 8.x → заменено на `from textual.message import Message`. -- `MountError` в `populate()` — `row.mount(child)` до прикрепления `row` к DOM → исправлено передачей детей в конструктор + `mount_all()`. -- `CSS_PATH` на каждом экране резолвился относительно файла класса и постоянно расходился с реальным расположением `.tcss` → **унифицировано**: один `app.tcss` с `CSS_PATH` только на `ServiceApp`. -- **Краш при drag мыши** (`assert isinstance(content_widget.parent, Widget)`) — `Screen` выступал `content_widget` напрямую → решено введением общего `AppFrame`-контейнера между `Screen` и содержимым. -- **`self._running`** в `DiagScreen` случайно совпало с приватным полем `MessagePump._running` из самого Textual → кнопка "Выйти" была перманентно заблокирована. Переименовано в `_tests_running`. -- `DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж **значений ячеек**, не `row_key` — пришлось сортировать по содержимому ячейки "Статус", которое сами полностью контролируем. -- `table.add_columns()` (множественное число) не принимает `width=` → колонка "Детали" обрезалась по длине заголовка. Исправлено через `add_column()` по одной с явной шириной. - -### Layout -- `AppFrame` с жёстким `width: 140; height: 44` обрезал контент на терминалах меньшего размера → сделан **адаптивным** (`width/height: 100%` с потолком `max-width: 160; max-height: 50`). -- `#diag-frame` на `layout: grid` с ручным расчётом `grid-rows` рассинхронизировался с реальным числом/высотой children (кнопки с `border: tall` не помещались в выделенную строку) → переход на `layout: vertical` с единственным `1fr` на рабочую зону. -- `#results-empty.hidden` не имел CSS-правила `display: none` → пустой контейнер с `height: 1fr` продолжал выталкивать таблицу результатов вниз даже будучи скрытым. - -### Логика приложения -- **`TestListPanel.set_enabled()`** путал постоянное состояние "HIL без M5" с временной блокировкой на время прогона → чекбоксы навсегда залипали disabled после первого запуска. Исправлено отдельным словарём `_hil_unavailable`. -- **Таймаут чтения порта** (`_recv_until`) тихо завершался без сигнала → зависший тест навсегда оставался в RUNNING, кнопки "Выйти"/запуска блокировались навсегда. Теперь генератор **гарантированно** завершается одним `SUMMARY` (настоящим или синтетическим), зависший тест получает `FAIL` с понятным detail. -- **`progress`-событие** протокола (документированное в PROTOCOL.md, используется тестом USD) ошибочно считалось неизвестным/ошибочным → теперь явно обрабатывается как `TEST_PROGRESS`. - -### Firmware (USD-тест) -- **USD зависает намертво на втором прогоне.** Причина: non-blocking USDHC host driver SDK оставался в состоянии "ожидание завершения транзакции" после `SD_HostDeinit()`, плюс структура `g_sd` не обнулялась между прогонами. Фикс: `USDHC_Reset(..., kUSDHC_ResetAll, ...)` + `memset(&g_sd, 0, ...)` в `bsp_sd_init()`/`bsp_sd_deinit()`. - ---- - -## 4. UX-доработки (по согласованному плану итераций) - -| Итерация | Что сделано | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Стабильность | `AppFrame`, скрытие ProgressBar в простое, кнопка "Выйти" на всех экранах | -| Workflow | `PostFlashScreen` (промпт смены BootMode после прошивки firmware_test — только для этого сценария), тесты изначально не выбраны + кнопки "Выбрать все"/"Снять все", полный UID в шапке | -| Polish | `ResultsPanel` переведён на `DataTable`: сортировка FAIL-наверх (стабильная внутри группы), подсветка FAIL-строки целиком, перенос длинных `detail` на несколько строк без обрезания, empty-state с подсказкой | -| Отчёт №2 | Убраны проценты в статус-баре (полоса осталась), мониторинг обрыва USB (`ConnectionWatcherMixin`) на `FlashScreen`/`DiagScreen` с разрывом сессии и понятным баннером причины на `WaitingScreen` | - ---- - -## 5. Текущее состояние - -**Готово и протестировано (headless):** -- Полный цикл: WaitingScreen → Flash/Diag → результат → возврат -- Прошивка (3 варианта) + chip erase + версионирование -- Диагностика: список тестов, выборочный/полный запуск, HIL через M5, кириллица -- Обработка обрывов: таймаут теста, потеря USB, повторные прогоны -- Адаптивная вёрстка на диапазоне терминалов 80×24 → 220×60 - -**Известные открытые вопросы / не доделано:** -- Release-сборка firmware нестабильна (медленное мигание — подозрение на проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно форсирует Debug через `FIRMWARE_BUILD_TYPE` -- `docs/DEV_ARCH.md`, `CHANGELOG.md`, `PLAN.md` — подготовлены диффы для финализации документации, но не применялись по твоему решению ("пока не буду обновлять документацию, нужно всё проверить") -- `tools/shared/m5_agent.py` — сознательно не делался: pytest HIL-окружение и TUI используют независимые M5-клиенты, признано правильным архитектурным решением, а не техдолгом - ---- - -## 6. Рекомендации для следующего треда - -1. Перед стартом — синхронизировать единую копию репозитория со всеми патчами из этого треда (было замечено расхождение версий файлов между чатом и локальной копией один раз, см. эпизод с TUI_REPORT.md). -2. Дальнейшее тестирование на реальном железе: полный цикл diagnostics с HIL (M5 подключён), повторные циклы прошивки/chip erase, граничные случаи USB-отключения во время разных операций. -3. Когда стабильность подтверждена — вернуться к обновлению `DEV_ARCH.md`/`CHANGELOG.md`/`PLAN.md` под финальную архитектуру. -4. Разобрать Release-сборку firmware (сравнить `hab_firmware_test_debug.yaml` vs `hab_firmware_test_release.yaml`). \ No newline at end of file diff --git a/tools/production/docs/TUI_SESSION_2.md b/tools/production/docs/TUI_SESSION_2.md deleted file mode 100644 index d4f960d..0000000 --- a/tools/production/docs/TUI_SESSION_2.md +++ /dev/null @@ -1,163 +0,0 @@ -# Отчёт: сессия доработки service-tui (продолжение) - -**Период:** один рабочий тред, продолжение после `TUI_SESSION.md` (архитектура и стабилизация Этапа 8) -**Объект:** `tools/production/` — TUI сервисного инженера -**Вход в сессию:** `TUI_PLAN.md` (6 пунктов), реальный HIL-стенд (M5StampPLC подключён) - ---- - -## 1. Пункт 6 — проверка HIL (CAN & Opto) ✅ ЗАКРЫТ - -M5StampPLC физически был подключён, но `DiagScreen` показывал "M5 Bench: ✕ нет связи". -Три независимых бага в одном файле `tools/production/app/m5_client.py` — рассинхрон -между тем, что реально шлёт `tools/hil/m5/agent.py`, и тем, что ожидал клиент TUI. -HIL pytest (`06_test_firmware_opto.py`/`06_test_firmware_can.py`) эти баги не ловил, -потому что ходит по своему пути — порт берёт напрямую из `.env` (`HIL_M5_PORT`), а не -через VID/PID автодетект, и не использует `M5Client` вовсе. - -| # | Баг | Было | Стало | Как нашли | -| --- | ------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | PID автодетекта M5 | `_M5_PID = 0x1001` (hardcode) | `_M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)` | `just host::m5-scan` показал `303a:4001`, не `303a:1001` — `0x1001` это PID ROM-режима ESP32-S3, `0x4001` — PID рантайма с запущенным MicroPython/агентом | -| 2 | Поле успеха ответа агента | `resp.get("status") == "ok"` (везде: `ping`, `relay_set`, `relay_get`, `can_send`, `can_recv`) | `resp.get("ok") is True` | Сверка с `HIL_BENCH.md`/`HIL_HOW_TO.md` — агент отвечает `{"ok": true, ...}`, поля `"status"` в протоколе агента нет вообще | -| 3 | Ключ канала реле | `{"cmd": "relay_set", "relay": relay, ...}` | `{"cmd": "relay_set", "ch": relay, ...}` (и `relay_get`) | Подтверждено `grep` по `tools/hil/m5/agent.py` — обработчик читает `cmd["ch"]`, ключа `"relay"` не существует | - -`_M5_VID` заодно вынесен в `os.environ` по аналогии с `SERVICE_CDC_VID/PID` в `app.py` -(был раньше hardcode `0x303A`, поведение не поменялось, только стал переопределяемым). - -`can_send`/`can_recv` сверены отдельно (`grep -A 12` по агенту) — багов не найдено, -формат ответа (`{"ok": true, "id": ..., "data": [...]}`) уже совпадал с ожиданиями клиента. - -**Подтверждено на живом стенде:** -``` -connected: True -ping: True -relay_set(3, True): True -relay_get(3): True -relay_set(3, False): True -``` -Прогон `opto`/`can` через `service-tui` целиком (не только pytest) — пройден, о чём -сообщил заказчик по итогу. Пункт 6 закрыт. - ---- - -## 2. Пункт 2 — единый размер активной области ✅ ЗАКРЫТ - -**Файл:** `tools/production/app/app.tcss`, блок `AppFrame`. - -```css -/* было */ -max-width: 160; -max-height: 50; - -/* стало */ -max-width: 112; -max-height: 35; -``` - -Итоговый размер `112×35` подобран и подтверждён визуально пользователем на реальных -скриншотах всех пяти экранов (`WAITING`, `WAITING_AFTER_LOSS`, `FLASH`, `POST_FLASH`, -`TEST_SCREEN`). Нижняя граница по ширине обоснована жёстко: `TestListPanel(width:38)` -+ `ResultsPanel(width:70)` рядом на `DiagScreen` дают 108 + рамка = 110 — меньше сжимать -уже нельзя. `width/height: 100%` (адаптивность под маленькие терминалы) не тронуты — -менялся только потолок. - ---- - -## 3. Пункт 3 — countdown в тесте buttons ❌ РЕШЕНО НЕ ДЕЛАТЬ - -Разобрал возможную реализацию (передать `timeout_ms` в `ConfirmPanel.show_buttons_hint()`, -завести отдельный `auto_fail`-флаг в таймере, чтобы не дёргать случайный -`Confirmed`-message в ветке buttons, где хост не должен ничего отправлять). Заказчик -решил не усложнять рабочую логику ради UX-мелочи — пункт закрыт без изменений в коде. - ---- - -## 4. Пункт 1 — bootlogo / логотип компании / центровка экранов ✅ ЗАКРЫТ - -Изначально запрошенная "чистка лога USB-POLL/TRANSFER" (часть А) — отменена заказчиком, -не актуальна. Весь пункт свёлся к части Б: логотип + центровка. - -### 4.1 Логотип — программная растеризация `logo.png` - -Ручная отрисовка ASCII-арта отклонена как ненадёжная — вместо этого написан -одноразовый скрипт (Pillow, вне рантайма приложения) с coverage-based растеризацией: - -1. Разбор `logo.png` (300×300 RGBA) на сетку символов с компенсацией соотношения - сторон шрифта терминала (`ASPECT = 0.5`). -2. Фон определялся не только по альфа-каналу (у файла альфа=0 только по внешним - углам), но и по близости цвета к белому (`r,g,b > 235`) — иначе весь непрозрачный - белый подложечный слой считался "закрашенным". -3. Классификация двух цветовых групп логотипа по каналам (`avgB - avgR > 15` → синий, - иначе тёмный/чёрный), калибровка порога — по гистограмме реальных цветов пикселей - (`(60,160,220)` синий кластер vs `(0,0,0)` чёрный кластер, чётко разделены). -4. Плотность символа на ячейку — по доле непрозрачных пикселей (`.::+*#` для синего, - `.::+%@` для тёмного), обрезка до bounding box контента → финальный размер 29×21. - -**Результат вынесен в новый файл** `tools/production/app/boot_art.py` — константа -`LOGO_ART` (Rich-markup строка, цвета `#3ca0dc` для синей части, `white` для тёмной, -`grey37` для фоновых точек). Никаких новых рантайм-зависимостей (Pillow использовался -только в песочнице для генерации, не входит в `pyproject.toml` TUI). - -### 4.2 Версия из `pyproject.toml` - -`tools/production/app/screens/waiting.py` — функция `_read_app_version()`, читает -`[project].version` напрямую через `tomllib` (stdlib, `requires-python >= 3.11` уже -задан). `importlib.metadata` сознательно не использован — проект не ставится как пакет -(`tool.uv.package = false` в `pyproject.toml`), метаданных может не быть. - -### 4.3 Финальная компоновка WaitingScreen - -- Убран старый текст `"TFT Indicator Board\nService Tool"`. -- Добавлена строка `"service_tool vX.Y.Z"` — **над** артом (не под), цвет `$warning` - (тот же оранжевый, что в заголовке `FlashScreen` — единообразие между экранами). -- `#waiting-frame`: `align: center middle` → `align: center top` (блок прижат к верху, - не висит по вертикальному центру). -- Баг центровки самого текста версии: `Static` без `text-align` растягивается на всю - ширину родителя и текст внутри прижимается к левому краю — обёртка `Center()` тут - не помогает (центрировать нечего, ребёнок и так 100% ширины). Фикс — `text-align: - center` в CSS на самом `#waiting-version`, а не контейнер-обёртка. -- Та же причина и тот же фикс повторно всплыли на `PostFlashScreen` для - `#post-flash-title` (`Label`) и `#post-flash-instruction` (`Static`, `width: auto`) — - здесь, наоборот, у элементов есть "естественная" ширина, поэтому раз уже `Center()` - вокруг них — верное решение (в отличие от `#waiting-version`, где элемент full-width). - Оба случая — одна и та же путаница между "центрировать full-width текст" (нужен - `text-align`) и "центрировать auto-width блок" (нужен `Center()`), просто с - противоположными по природе виджетами. - -**Файлы, изменённые в рамках пункта 1:** -- `tools/production/app/boot_art.py` — новый, константа `LOGO_ART` -- `tools/production/app/screens/waiting.py` — `compose()`, `_read_app_version()` -- `tools/production/app/screens/post_flash.py` — `compose()` (обёртки `Center()`) -- `tools/production/app/app.tcss` — секции `WaitingScreen`, `PostFlashScreen` - ---- - -## 5. Текущее состояние `TUI_PLAN.md` - -| # | Пункт | Статус | -| --- | -------------------------------------------- | -------------------------------------------- | -| 1 | Экраны USB-POLL/TRANSFER, bootlogo | ✅ закрыт (часть А отменена, часть Б сделана) | -| 2 | Единый размер активной области | ✅ закрыт (112×35) | -| 3 | Countdown в тесте buttons | ❌ решено не делать | -| 4 | Экспорт результатов в JSON с привязкой к UID | ⏸ отложен | -| 5 | Копирование UID с экрана терминала | ⏸ отложен | -| 6 | Проверка HIL (CAN & Opto) | ✅ закрыт, 3 бага найдены и исправлены | - -Пункты 4 и 5 сохранены в памяти для следующих сессий — не начаты, ждут возврата. - ---- - -## 6. Рекомендации для следующего треда - -1. Перед стартом пунктов 4/5 — обсудить формат JSON-экспорта (структура файла, - куда сохраняется, привязка к UID в имени/содержимом) и способ копирования UID - (зависит от поддержки OSC 52 в целевом терминале сервисника — стоит уточнить - заранее, SSH-сессии могут не поддерживать). -2. `m5_client.py` теперь соответствует реальному протоколу `agent.py` — при любых - будущих изменениях `agent.py` (новые команды, смена формата ответа) стоит сразу - сверяться через `grep` по агенту, а не полагаться на `HIL_BENCH.md`/`HIL_HOW_TO.md` - (документация местами не успевала за кодом — минимум один пример уже был найден). -3. `LOGO_ART` в `boot_art.py` — статичный артефакт. Если лого компании поменяется, - скрипт растеризации не сохранён в репозитории (был одноразовым в песочнице) — - при необходимости регенерации нужно будет написать заново (логика описана в - разделе 4.1 этого отчёта, воспроизводима). \ No newline at end of file diff --git a/tools/production/pyproject.toml b/tools/production/pyproject.toml index 20b5d72..f78c4d1 100644 --- a/tools/production/pyproject.toml +++ b/tools/production/pyproject.toml @@ -8,7 +8,8 @@ dependencies = [ "pyserial>=3.5", "python-dotenv>=1.0.0", "pyinstaller>=6.0.0", - "pyusb>=1.0.0", + "pyusb>=1.0.0", + "spsdk==3.7.0", ] [project.scripts] @@ -17,6 +18,9 @@ service-tui = "main:main" [tool.uv] package = false +[dependency-groups] +dev = ["pytest>=8.0.0"] + [build-system] requires = ["hatchling"] -build-backend = "hatchling.build" \ No newline at end of file +build-backend = "hatchling.build" diff --git a/tools/production/spike/spike_flash.py b/tools/production/spike/spike_flash.py new file mode 100644 index 0000000..95506e8 --- /dev/null +++ b/tools/production/spike/spike_flash.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +spike_flash.py — Фаза 0 (де-риск): SDP/Flashloader/McuBoot через spsdk +Python API вместо subprocess sdphost/blhost. + +Закрывает: + ⚠В2 — способ задать таймаут ≥200с для flash_erase_all (эквивалент + `blhost -t 200000`): interface.device.timeout — МИЛЛИСЕКУНДЫ + (проверено чтением spsdk/utils/interfaces/device/usb_device.py; + докстрока UsbDevice.scan() говорит "секунды" — по факту мс, + default=2000, что явно мс, а не секунды). + Р7 — сигнатуры SdpUSBInterface.scan()/MbootUSBInterface.scan() + (device_id="0xVVVV:0xPPPP", HID-транспорт через libusbsio, + pyusb в детекте не участвует). + О1 — вывод list_ports.comports() для CDC-устройств (firmware_test, + M5StampPLC) с явными VID:PID — для ручной сверки на Windows. + +Прогнать на Windows-машине БЕЗ Zadig — это и есть проверка Р7. + +По умолчанию скрипт НЕ деструктивен: детект SDP → загрузка Flashloader → +get_property → configure_memory. Chip erase — только с --erase-all и +интерактивным подтверждением (как в flash_usb.py). + +Запуск: + uv run python spike_flash.py # детект + get_property + uv run python spike_flash.py --ports-only # только serial-порты (О1) + uv run python spike_flash.py --erase-all # + chip erase (ДЕСТРУКТИВНО) +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path +from typing import Optional + +from spsdk.mboot import McuBoot, MbootUSBInterface +from spsdk.mboot.properties import PropertyTag +from spsdk.sdp import SDP, SdpUSBInterface + +# tools/production/spike/spike_flash.py → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +FLASHLOADER_BIN = REPO_ROOT / "tools" / "host" / "dcd" / "ivt_flashloader.bin" + +FLASHLOADER_LOAD_ADDR = 0x20001C00 +FLEXSPI_OPTION_ADDR = 0x2000 +FLEXSPI_OPTION_VALUE = 0xC0000007 +FLEXSPI_MEMORY_ID = 9 +ERASE_ALL_TIMEOUT_MS = 200_000 # эквивалент blhost -t 200000, см. docstring + + +def _device_id(vid_env: str, vid_default: str, pid_env: str, pid_default: str) -> str: + """Тот же паттерн, что _usb() в tools/host/flash_usb.py, но в формате + spsdk USBDeviceFilter: "0xVVVV:0xPPPP".""" + vid = os.environ.get(vid_env, vid_default).strip().lower().removeprefix("0x") + pid = os.environ.get(pid_env, pid_default).strip().lower().removeprefix("0x") + return f"0x{vid}:0x{pid}" + + +SDP_ID = _device_id("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130") +FLASHLOADER_ID = _device_id("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073") + + +def enumerate_serial_ports() -> None: + """О1 + вопрос про Windows CDC: печатает VID:PID/описание всех serial- + портов. firmware_test (1996:00AD) — class-compliant CDC ACM, драйвер + не нужен ни на одной ОС (Windows 10+ грузит usbser.sys по классу + интерфейса, не по VID:PID). M5StampPLC — открытый вопрос О1: нативный + ESP32-S3 CDC (VID Espressif 303A, драйвер тоже не нужен) или мост + CH9102/CP210x (на изолированной Windows потребует один вендорский + драйвер — честная ОС-необходимость, не Zadig-костыль).""" + import serial.tools.list_ports as list_ports + + print("=== Serial-порты (serial.tools.list_ports.comports) ===") + ports = list(list_ports.comports()) + if not ports: + print(" (ничего не найдено)") + return + for info in ports: + vidpid = ( + f"{info.vid:04X}:{info.pid:04X}" if info.vid is not None else "----:----" + ) + print( + f" {info.device:20s} VID:PID={vidpid} " + f"{info.description!r} serial={info.serial_number!r}" + ) + print( + "\n Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC " + "запиши VID:PID из вывода выше — это и есть измерение О1 " + "(см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5)." + ) + + +def wait_for_flashloader(timeout_s: float = 10.0) -> Optional[MbootUSBInterface]: + print(f" Ожидание Flashloader (до {timeout_s:.0f}с)...", end="", flush=True) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + found = MbootUSBInterface.scan(device_id=FLASHLOADER_ID) + if found: + print(" OK") + return found[0] + time.sleep(0.5) + print(".", end="", flush=True) + print(" TIMEOUT") + return None + + +def load_flashloader() -> Optional[MbootUSBInterface]: + """SDP.write_file + jump_and_run — прямой аналог load_flashloader() + из tools/host/flash_usb.py, но через spsdk API вместо sdphost.""" + already = MbootUSBInterface.scan(device_id=FLASHLOADER_ID) + if already: + print(" Flashloader уже запущен — пропускаем загрузку") + return already[0] + + sdp_devices = SdpUSBInterface.scan(device_id=SDP_ID) + if not sdp_devices: + print(f" SDP-устройство не найдено ({SDP_ID}). Плата в BootROM-режиме?") + return None + + if not FLASHLOADER_BIN.exists(): + print(f" Не найден: {FLASHLOADER_BIN}") + return None + + print(f" Загрузка Flashloader через SDP ({SDP_ID})") + data = FLASHLOADER_BIN.read_bytes() + with SDP(sdp_devices[0]) as sdp: + sdp.write_file(FLASHLOADER_LOAD_ADDR, data) + sdp.jump_and_run(FLASHLOADER_LOAD_ADDR) + + return wait_for_flashloader() + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--ports-only", + action="store_true", + help="Только serial-порты (О1), без SDP/Flashloader", + ) + parser.add_argument( + "--erase-all", action="store_true", help="ДЕСТРУКТИВНО: chip erase после detect" + ) + args = parser.parse_args() + + enumerate_serial_ports() + if args.ports_only: + return 0 + + print(f"\n=== SDP/Flashloader (SDP={SDP_ID}, Flashloader={FLASHLOADER_ID}) ===") + iface = load_flashloader() + if iface is None: + print("\n❌ Flashloader не поднялся — дальнейшие шаги пропущены") + return 1 + + with McuBoot(iface) as mboot: + version = mboot.get_property(PropertyTag.CURRENT_VERSION) + print(f"\n✅ get_property(CURRENT_VERSION) = {version}") + + print("\n=== configure_memory (FlexSPI NOR) ===") + mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE) + ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID) + print(f"configure_memory: {'OK' if ok else 'FAILED'}") + + if args.erase_all: + print( + "\n⚠️ Chip erase сотрёт FCB — плата не загрузится до повторной прошивки." + ) + confirm = input("Наберите ERASE для подтверждения: ") + if confirm != "ERASE": + print("Отменено.") + return 0 + print( + f" Выставляю timeout={ERASE_ALL_TIMEOUT_MS}мс (эквивалент blhost -t 200000)" + ) + iface.device.timeout = ERASE_ALL_TIMEOUT_MS + ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID) + print(f"flash_erase_all: {'OK' if ok else 'FAILED'}") + mboot.reset(reopen=False) + + print("\n✅ Гейт 0 (SDP/Flashloader): пройден") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/production/spike/spike_hab.py b/tools/production/spike/spike_hab.py new file mode 100644 index 0000000..fa5f774 --- /dev/null +++ b/tools/production/spike/spike_hab.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +spike_hab.py — Фаза 0 (де-риск): HAB через HabImage (spsdk Python API) +вместо subprocess `nxpimage hab export`. + +Закрывает ⚠В1: подтверждает, что HabImage.load_from_config(...).export() +даёт побайтно идентичный `nxpimage hab export` результат с тем же +конфигом, для DCD on/off. + +Реальный вызов подсмотрен в исходнике самого nxpimage — +spsdk/apps/nxpimage_apps/nxpimage_hab.py::hab_export() делает ровно +следующее (это не внутренний вызов apps-модуля, а повтор того же кода +на публичных классах Config/HabImage): + + cfg = Config.create_from_file(yaml_path) + schemas = HabImage.get_validation_schemas_from_cfg(cfg) + cfg.check(schemas, check_unknown_props=True) + hab = HabImage.load_from_config(cfg) + hab.post_export(cfg.config_dir) + data = hab.export() + +Временный YAML пишется в системный tmp (Р6) с АБСОЛЮТНЫМИ путями +inputImageFile/DCDFilePath — в отличие от текущего flasher.py, который +полагается на cwd=tools/host/hab/ + относительный "../dcd/dcd.bin". +Config.get_input_file_name() резолвит путь через find_file(search_paths= +[cfg_dir]), а абсолютный путь find_file отдаёт как есть независимо от +search_paths (проверено чтением spsdk/utils/config.py) — значит схема +Р6 (temp где угодно, не обязательно рядом с hab/) технически безопасна. + +Содержимое "прошивки" для golden-теста не имеет значения — сравнивается +результат УПАКОВКИ (IVT/BDT/[DCD]/APP), а не семантика кода, поэтому +используется детерминированный dummy-бинарь. DCD, наоборот, должен быть +настоящей HAB DCD-командной последовательностью (SegDCD.parse иначе +падает) — поэтому DCD-кейс использует штатный tools/host/dcd/dcd.bin; +если его нет рядом — тест скипается, а не подделывается фиктивным DCD. + +Запуск: + uv run pytest spike_hab.py -v -s # golden-тест (без железа) + uv run python spike_hab.py # то же + подробный вывод в консоль + uv run python spike_hab.py --dcd-bin /path/to/other_dcd.bin +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Optional + +import pytest +from spsdk.image.hab.hab_image import HabImage +from spsdk.utils.config import Config + +# tools/production/spike/spike_hab.py → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +REAL_DCD_BIN = REPO_ROOT / "tools" / "host" / "dcd" / "dcd.bin" + +_HAB_OPTIONS = [ + "options:", + " flags: 0x00", + " startAddress: 0x60000000", + " ivtOffset: 0x1000", + " initialLoadSize: 0x2000", + " family: mimxrt1050", +] + + +def _make_dummy_app(size: int = 512) -> bytes: + """Детерминированные псевдослучайные байты фиксированного размера. + + Не настоящая прошивка — см. docstring модуля: для golden-теста важна + побайтная идентичность УПАКОВКИ, а не валидность кода приложения. + """ + seed = hashlib.sha256(b"tft-monolith-phase0-golden-hab").digest() + return (seed * (size // len(seed) + 1))[:size] + + +def _write_config(input_bin: Path, dcd_bin: Optional[Path], work_dir: Path) -> Path: + """Собрать YAML-конфиг nxpimage hab (абсолютные пути, см. docstring модуля).""" + lines = list(_HAB_OPTIONS) + if dcd_bin is not None: + lines.append(f" DCDFilePath: {dcd_bin.resolve().as_posix()}") + lines.append(f'inputImageFile: "{input_bin.resolve().as_posix()}"') + lines.append("sections: []") + + suffix = "dcd" if dcd_bin is not None else "nodcd" + yaml_path = work_dir / f"hab_golden_{suffix}.yaml" + yaml_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return yaml_path + + +def build_via_api(yaml_path: Path) -> bytes: + """Собрать HAB-образ через spsdk Python API — то же самое, что делает + `nxpimage hab export` изнутри (см. docstring модуля).""" + cfg = Config.create_from_file(str(yaml_path)) + schemas = HabImage.get_validation_schemas_from_cfg(cfg) + cfg.check(schemas, check_unknown_props=True) + hab = HabImage.load_from_config(cfg) + hab.post_export(cfg.config_dir) + return hab.export() + + +def _require_nxpimage() -> str: + path = shutil.which("nxpimage") + if path is None: + raise RuntimeError( + "nxpimage не найден в PATH — активирован ли venv tools/production " + "(spsdk кладёт nxpimage как console_script)?" + ) + return path + + +def build_via_cli(yaml_path: Path, out_bin: Path) -> bytes: + """Собрать тот же образ через `nxpimage hab export` (эталон сравнения).""" + nxpimage = _require_nxpimage() + subprocess.run( + [ + nxpimage, + "hab", + "export", + "--force", + "-c", + str(yaml_path), + "-o", + str(out_bin), + ], + check=True, + capture_output=True, + text=True, + ) + return out_bin.read_bytes() + + +def _run_golden(work_dir: Path, dcd_bin: Optional[Path]) -> tuple[bytes, bytes]: + input_bin = work_dir / "dummy_app.bin" + input_bin.write_bytes(_make_dummy_app()) + yaml_path = _write_config(input_bin, dcd_bin, work_dir) + api_bytes = build_via_api(yaml_path) + cli_bytes = build_via_cli(yaml_path, work_dir / "out_cli.bin") + return api_bytes, cli_bytes + + +# ── pytest: остаётся навсегда как регрессия на апгрейды spsdk (Гейт 0) ── +# Примечание: сейчас лежит в spike/ (временная директория по плану Фазы 0); +# в Фазе 1 у tools/production/ появляется tests/ — тогда этот файл стоит +# туда перенести (или вынести тесты в отдельный test_hab_golden.py). + + +def test_golden_hab_no_dcd(tmp_path: Path) -> None: + api_bytes, cli_bytes = _run_golden(tmp_path, dcd_bin=None) + assert api_bytes == cli_bytes, ( + f"HabImage API разошёлся с nxpimage CLI (DCD off): " + f"{len(api_bytes)} vs {len(cli_bytes)} байт" + ) + + +def test_golden_hab_with_dcd(tmp_path: Path) -> None: + if not REAL_DCD_BIN.exists(): + pytest.skip(f"Реальный DCD не найден: {REAL_DCD_BIN}") + api_bytes, cli_bytes = _run_golden(tmp_path, dcd_bin=REAL_DCD_BIN) + assert api_bytes == cli_bytes, ( + f"HabImage API разошёлся с nxpimage CLI (DCD on): " + f"{len(api_bytes)} vs {len(cli_bytes)} байт" + ) + + +# ── standalone запуск: то же самое, но с подробным выводом в консоль ──── + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--dcd-bin", type=Path, default=REAL_DCD_BIN, help="Путь к реальному dcd.bin" + ) + args = parser.parse_args() + + try: + _require_nxpimage() + except RuntimeError as exc: + print(f"❌ {exc}") + return 1 + + work_dir = Path(tempfile.mkdtemp(prefix="spike_hab_")) + print(f"Рабочая директория: {work_dir}") + + ok = True + for dcd_bin, label in [(None, "DCD off"), (args.dcd_bin, "DCD on")]: + print(f"\n--- {label} ---") + if dcd_bin is not None and not dcd_bin.exists(): + print(f" ПРОПУЩЕНО: DCD-файл не найден: {dcd_bin}") + continue + try: + api_bytes, cli_bytes = _run_golden(work_dir, dcd_bin) + match = api_bytes == cli_bytes + print(f" API: {len(api_bytes)} байт, CLI: {len(cli_bytes)} байт") + print(f" {'✅ ПОБАЙТНО СОВПАДАЕТ' if match else '❌ РАСХОЖДЕНИЕ'}") + ok = ok and match + except subprocess.CalledProcessError as exc: + print(f" ❌ nxpimage CLI упал: {exc.stderr}") + ok = False + except Exception as exc: # noqa: BLE001 — спайк, важен весь контекст ошибки + print(f" ❌ {type(exc).__name__}: {exc}") + ok = False + + print(f"\nРезультат: {'✅ Гейт 0 (HAB) пройден' if ok else '❌ Стоп-условие В1'}") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())