# Spike tests: Windows & MacOS spsdk behaviour

This commit is contained in:
Dmitry Akimov 2026-07-02 13:50:23 +03:00
parent 477c0d6205
commit 1801f1beb9
8 changed files with 1071 additions and 287 deletions

View file

@ -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
### Кратко

View file

@ -0,0 +1,452 @@
# service-tui — миграция на монолит (V4), USB-кроссплатформенность и релиз
> Единый рабочий документ. Объединяет и заменяет `MONOLITH_PLAN.md`
> и `USB_CROSSPLATFORM.md`; заменяет шаг 3 («Упаковка», вариант B
> с venv) в `RELEASE_PLAN.md`. Шаги 12 плана релиза (merge, CHANGELOG)
> выполнены и не затрагиваются; шаги 46 переезжают в фазы 56.
Ветка: `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` в фазах
13 не редактируются. Главный контейнер регрессии.
- 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, релиз (шаги 56 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
- [ ] Документация синхронизирована (железо подтверждено гейтами 35).
- [ ] `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-парсинга | 12 | Поэтапный прогресс (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`.

View 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`) — тег релиза
предлагается синхронизировать с этим значением.
**Статус:** не начато, ждёт шагов 15.

View file

@ -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`).

View file

@ -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 этого отчёта, воспроизводима).

View file

@ -9,6 +9,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"pyinstaller>=6.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"

View 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())

View 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())