# Spike tests: Windows & MacOS spsdk behaviour
This commit is contained in:
parent
477c0d6205
commit
1801f1beb9
8 changed files with 1071 additions and 287 deletions
80
CHANGELOG.md
80
CHANGELOG.md
|
|
@ -58,6 +58,84 @@
|
||||||
- Следить за развитием BSP: MQS, RGB, bootloader или `tft_app`.
|
- Следить за развитием BSP: MQS, RGB, bootloader или `tft_app`.
|
||||||
- Отслеживать локальные патчи поверх vendor SDK, которые нужно вести отдельным patch log.
|
- Отслеживать локальные патчи поверх 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
|
## [2026-06-29] — Этапы 6г–7: MQS, HIL pytest firmware_test, Provisioning
|
||||||
|
|
||||||
### Кратко
|
### Кратко
|
||||||
|
|
@ -609,4 +687,4 @@
|
||||||
- Упоминать изменения README только тогда, когда они влияют на onboarding, понимание API, инструкции сборки/тестирования или архитектуру проекта.
|
- Упоминать изменения README только тогда, когда они влияют на onboarding, понимание API, инструкции сборки/тестирования или архитектуру проекта.
|
||||||
- Упоминать изменения тестовой инфраструктуры, когда они затрагивают host tests, HIL tests, firmware-side test modules, fixtures, runners, protocol commands или expected coverage.
|
- Упоминать изменения тестовой инфраструктуры, когда они затрагивают 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.
|
- Упоминать CI changes, когда меняются workflows, local CI tasks, artifacts, triggers, status или self-hosted runner strategy.
|
||||||
- Молчать, если новых коммитов нет или найден только отфильтрованный шум.
|
- Молчать, если новых коммитов нет или найден только отфильтрованный шум.
|
||||||
452
tools/production/docs/MONOLITH_APP_PLAN.md
Normal file
452
tools/production/docs/MONOLITH_APP_PLAN.md
Normal file
|
|
@ -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 <flashloader>` | `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 <size> 0` | `McuBoot.flash_erase_region(0x60000000, size)` |
|
||||||
|
| `blhost ... write-memory <addr> <file> 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 <yaml> -o <bin>` | `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. Задан <NAME>_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 —
|
||||||
|
`<repo>/build/<Type>/<name>_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/<variant>_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-<os>/
|
||||||
|
├── 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`.
|
||||||
128
tools/production/docs/RELEASE_PLAN.md
Normal file
128
tools/production/docs/RELEASE_PLAN.md
Normal file
|
|
@ -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-<os>/
|
||||||
|
├── 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-режим — как сейчас (`<repo_root>/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.
|
||||||
|
|
@ -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`).
|
|
||||||
|
|
@ -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 этого отчёта, воспроизводима).
|
|
||||||
|
|
@ -8,7 +8,8 @@ dependencies = [
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pyinstaller>=6.0.0",
|
"pyinstaller>=6.0.0",
|
||||||
"pyusb>=1.0.0",
|
"pyusb>=1.0.0",
|
||||||
|
"spsdk==3.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
@ -17,6 +18,9 @@ service-tui = "main:main"
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8.0.0"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
|
||||||
190
tools/production/spike/spike_flash.py
Normal file
190
tools/production/spike/spike_flash.py
Normal file
|
|
@ -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())
|
||||||
216
tools/production/spike/spike_hab.py
Normal file
216
tools/production/spike/spike_hab.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue