# firmware_test: TUI app docs

This commit is contained in:
Dmitry Akimov 2026-07-01 15:00:52 +03:00
parent 0132d8056d
commit 2a6256e745
12 changed files with 755 additions and 604 deletions

View file

@ -3,7 +3,7 @@
cmake_minimum_required(VERSION 3.20)
project(
firmware_test
VERSION 0.0.1
VERSION 0.1.1
LANGUAGES C ASM)
set(TARGET_NAME firmware_test)

View file

@ -3,412 +3,124 @@
TUI-приложение для диагностики и прошивки платы **MIMXRT1052CVJ5B** на сервисе.
Написано на Python + [Textual](https://textual.textualize.io/). Работает на Linux, macOS, Windows.
> Внутреннее устройство, диаграммы архитектуры и заметки для разработчиков —
> в [DEV_ARCH.md](DEV_ARCH.md). Этот документ — только про то, как приложением
> пользоваться.
---
## Структура проекта
## Экраны приложения
### Ожидание подключения (WaitingScreen)
Стартовый экран. Автодетект USB — как только плата подключена, TUI сам
определяет режим (прошивка или диагностика) и переключается.
```bash
tools/production/
├── main.py ← точка входа (10 строк)
├── pyproject.toml ← зависимости uv
├── uv.lock
└── app/
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
├── app.tcss ← единый файл стилей для всех экранов
├── models.py ← все типы данных (dataclass/Enum)
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── flasher.py ← subprocess-обёртка над tools/host/flash_usb.py
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
├── widgets/
│ ├── __init__.py
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
└── screens/
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen
├── waiting.py ← WaitingScreen — ожидание USB, баннер причины возврата
├── flash.py ← FlashScreen — прошивка / chip erase
├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
└── diag/
├── __init__.py ← DiagScreen — координатор диагностики
├── test_list.py ← TestListPanel — чекбоксы тестов, Выбрать/Снять все
├── results.py ← ResultsPanel — DataTable результатов
└── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown
┌────────────────────────────────────────────────────┐
│ service_tool v0.3.0 │
│ │
│ [LOGO_ART] │
│ │
│ Подключите плату индикатора к USB... ⠋ │
└────────────────────────────────────────────────────┘
```
---
При потере соединения на любом другом экране сессия разрывается полностью —
TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над
подсказкой на 4 секунды появляется строка `⚠ <причина>` (например,
«⚠ Соединение с платой потеряно»), затем скрывается сама — обычный
автодетект продолжается без вмешательства оператора.
## Концепция
### Прошивка платы (FlashScreen)
```mermaid
graph LR
subgraph PC["Сервисный ПК"]
TUI["service-tui\n(Textual App)"]
subgraph app["app/"]
FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
M5["m5_client.py\nSerial JSON-lines, UTF-8"]
FL["flasher.py\nsubprocess + pyusb detect"]
OR["orchestrator.py\nconfirm/progress/timeout router"]
end
TUI --> FC & M5 & FL & OR
end
subgraph Board["Плата TFT (MIMXRT1052)"]
FW["firmware_test\n(USB CDC)"]
ROM["BootROM SDP\n(1FC9:0130)"]
end
subgraph HIL["HIL стенд (опционально)"]
M5HW["M5StampPLC\nRLY14 + CAN"]
end
subgraph Host["tools/host/"]
FU["flash_usb.py\nsdphost + blhost"]
end
FC <-->|"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL -->|"subprocess uv run"| FU
FU -->|"sdphost + blhost\nVID:PID 1FC9:0130"| ROM
M5 <-->|"JSON-lines\nSerial"| M5HW
M5HW -->|"RLY14"| Board
```
> **Детект USB:** `Flasher.detect_sdp()`/`detect_cdc()` используют `pyusb` как основной метод (BootROM SDP не создаёт serial-порт на macOS и невидим через `pyserial.list_ports`), с fallback на `serial.tools.list_ports` для CDC.
---
## Состояния приложения
Состояние определяется автодетектом USB и меняется динамически без перезапуска TUI. При потере соединения сессия разрывается полностью — TUI не пытается восстановить прежнее состояние, а стартует заново с `WaitingScreen`.
```mermaid
stateDiagram-v2
[*] --> WAITING : запуск TUI
WAITING --> FLASHING : VID:PID 1FC9:0130\n(BootROM SDP)
WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC
FLASHING --> POST_FLASH : firmware_test прошит успешно
FLASHING --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое
POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое
DIAGNOSING --> FLASHING: плата переведена в SDP (перемычка BOOT_MOD)
```
### Режим A — Прошивка (FlashScreen)
Триггер: BootROM SDP `1FC9:0130` обнаружен через `pyusb`.
Триггер: обнаружена плата в режиме BootROM SDP.
```bash
┌─ Прошивка платы ─────────────────────────────────┐
│ ⚡ BootROM SDP обнаружен │
┌────────────────────────────────────────────────────┐
│ ⚡ Загрузка прошивки на плату индикатора │
│ (режим BootROM) │
│ │
│ Что прошить? │
│ ◉ firmware_test (диагностическая прошивка) │
│ ○ Production (bootloader + tft_app) │
│ ○ Кастомный бинарь... │
│ Выбор загружаемой прошивки │
│ ◉ Диагностическая прошивка (firmware_test) │
│ ○ Серийная прошивка (bootloader + tft_app) │
│ ○ Другое │
│ [ Имя бинарного файла в custom_binaries/ (.bin) ]│ ← только если «Другое»
│ │
│ [ ▶ Прошить ] [ ⚠ Chip Erase ] [ ✕ Выйти ]
│ [ ▶ Загрузить ] [ ⚠ Очистить память ] [ ✕ Выйти из приложения ] │
│ │
│ ████████████░░░░░░ ← без числового % │
│ ┌────────────────────────────────────────────┐ │
│ │ ▶ Прошивка: firmware_test │ │
│ │ $ blhost -u 0x15A2,0x0073 -- write-memory… │ │
│ │ ... │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
```
ProgressBar виден только во время активной операции (скрыт в простое), без числового `%` — только полоса и построчный лог в реальном времени.
Лог виден постоянно (не только во время прошивки), прогресс-бар — только
во время активной операции (скрыт в простое), без числового `%` — только
полоса и построчный лог в реальном времени.
### Промежуточный экран — PostFlashScreen
### Переход в рабочий режим (PostFlashScreen)
Показывается **только** после успешной прошивки `firmware_test` (не для Production/Custom — им этот шаг не нужен).
Показывается **только** после успешной прошивки `firmware_test` (для
Production/Custom этот шаг не нужен).
```bash
┌─ Прошивка завершена ──────────────────────────────┐
┌────────────────────────────────────────────────────┐
│ ✅ firmware_test успешно записан │
│ │
│ Переведите плату в нормальный режим: │
│ BOOT_MOD_1 → GND → Reset │
│ │
│ [ ✓ Готово, перешёл ] [ ✕ Выйти ]
│ [ ✓ Готово ] [ ✕ Выйти из приложения ] │
│ Автопереход через: 40с
└────────────────────────────────────────────────────┘
```
### Режим B — Диагностика (DiagScreen)
### Диагностика (DiagScreen)
Триггер: CDC-порт `1996:00AD` виден + `ping→pong` прошёл.
Триггер: плата видна как CDC-устройство и отвечает на связь.
```bash
┌─ Диагностика fw:0.2.0 UID:A1B2C3D4E5F60011 M5: ✓ подключён ─┐
│ Тесты │ Результаты (DataTable) │
│ [Выбрать все][Снять все] │ Тест HIL Статус Время │
│ ☐ SDRAM 32 MB │ microSD ✗ FAIL 0.1с
│ ☐ QSPI Flash │ no card detected (без обрезки) │
│ ☐ microSD │ SDRAM 32 MB ✓ PASS 1.8с
│ ☐ TFT Display │ QSPI Flash ✓ PASS 0.6с
│ ☐ CAN loopback [HIL] │ TFT Display … running │
│ ☐ Оптовходы [HIL] │ Кнопки pending │
├─────────────────────────────────────────────────────────────────┤
│ [▶ Запустить выбранные] [▶▶ Все тесты] [✕ Выйти] │
│ ████████░░░░ Тест: usd — mount: ok │
├─────────────────────────────────────────────────────────────────┤
│ ⚠ Экран залит красным цветом? 28с
│ [ ✓ Да ] [ ✗ Нет ] │
└─────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ fw: 0.2.0 MCU ID: A1B2C3D4E5F60011 M5 Bench: ✓ подключён │
├───────────────────────────┬──────────────────────────────────────┤
│ Доступные тесты │ Тест M5 Bench Статус Время │
│ [Выбрать все][Снять все] │ microSD [-] ✗ FAIL 0.1с
│ ☐ SDRAM 32 MB │ no card detected (без обрезки) │
│ ☐ QSPI Flash │ SDRAM 32 MB [-] ✓ PASS 1.8с
│ ☐ microSD │ QSPI Flash [-] ✓ PASS 0.6с
│ ☐ TFT Display │ TFT Display [-] … │
│ ☐ CAN loopback [HIL] │ Кнопки [-] │
│ ☐ Оптовходы [HIL] │ │
├──────────────────────────────────────────────────────────────────┤
│ ████████░░░░ Тест: usd — mount: ok ← только во время прогона │
├──────────────────────────────────────────────────────────────────┤
│ [▶ Запустить выбранные тесты] [▶▶ Запустить все тесты] [✕ Выйти из приложения] │
├──────────────────────────────────────────────────────────────────┤
│ ⚠ Экран залит красным цветом? 28с
│ [ ✓ Да ] [ ✗ Нет ] ← только во время confirm │
└────────────────────────────────────────────────────────────────┘
```
Ключевые отличия от ранних версий TUI:
- **Тесты изначально не выбраны** — сервисник выбирает явно, либо кнопками "Выбрать все"/"Снять все"
- **Результаты — `DataTable`**, не текстовые строки: сортировка FAIL-наверх (стабильная внутри группы по порядку реестра), FAIL-строка подсвечена красным фоном целиком, длинные `detail`-сообщения переносятся на несколько строк без обрезания (явная ширина колонок, см. раздел «Известные грабли Textual»)
- **`progress`-события** теста USD (`card_detect`, `mount`, `write`, `read_compare`) отображаются в прогресс-строке как текущая фаза
- HIL-тесты без M5StampPLC — серые, недоступны для выбора (постоянное состояние, не путается с временной блокировкой во время прогона)
---
## Обработка confirm_request
Маршрутизация реализована в `Orchestrator._handle_confirm()` по значению `confirm_request.id`. Помимо confirm, протокол v2 определяет `progress` — внутришаговые информационные события долгих тестов (сейчас только `usd`), не требующие ответа.
```mermaid
flowchart TD
EV["Событие от firmware_test"]
EV --> T{type}
T -->|"confirm_request"| R{confirm_request.id}
T -->|"progress"| PROG["TEST_PROGRESS\nотобразить фазу в прогресс-строке"]
T -->|"test_begin / test_result / summary"| STD["стандартная обработка"]
T -->|"_timeout (синтетическое,\nот FirmwareClient)"| TO["синтезировать FAIL\nдля зависшего теста\n+ гарантированный SUMMARY"]
T -->|"неизвестный тип"| LOG["logger.debug — НЕ ошибка,\nне показывается оператору"]
R -->|"opto_*"| HIL_OPTO["M5: relay_set → settle → send_confirm"]
R -->|"can_rx_ready"| HIL_CAN_RX["M5: can_send → send_confirm"]
R -->|"can_tx_verify"| HIL_CAN_TX["M5: can_recv → verify → send_confirm"]
R -->|"btn*"| BTN["show_buttons_hint, БЕЗ JSON-confirm"]
R -->|"остальное"| OP["show_operator + countdown\nждать resolve_operator_confirm()"]
```
| `confirm_request.id` | Кто отвечает | Реле M5 |
| ------------------------------- | ---------------------------- | ------- |
| `opto_in1_active` / `_inactive` | M5 авто | RLY3 |
| `opto_in2_active` / `_inactive` | M5 авто | RLY4 |
| `opto_rs_active` / `_inactive` | M5 авто | RLY2 |
| `can_rx_ready` | M5 авто (CAN TX) | — |
| `can_tx_verify` | M5 авто (CAN RX) | — |
| `btn*` | физика, без JSON-ответа | — |
| всё остальное | оператор, prompt + countdown | — |
**Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно одним событием `SUMMARY` — настоящим от firmware или синтетическим (`aborted: true`), если чтение порта оборвалось по таймауту. Без этой гарантии зависший тест блокировал бы кнопки "Выйти" и повторного запуска навсегда (исторический баг, см. CHANGELOG).
---
## Мониторинг соединения и разрыв сессии
`ConnectionWatcherMixin` (`screens/connection_watcher.py`) подключается к `FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине.
- **На `FlashScreen`** — проверка приостановлена во время активной прошивки/erase (обрыв обнаружит сам `flash_usb.py` subprocess).
- **На `DiagScreen`** — проверка приостановлена во время прогона тестов (обрыв надёжнее детектирует таймаут чтения порта внутри `Orchestrator`, не просто исчезновение устройства из списка).
- При срабатывании — `ConnectionLost` message → экран постит `FlashDone(success=False, target=None)` / `DiagDone(reason=...)``ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает на `WaitingScreen(disconnect_reason=...)`.
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем продолжает обычный автодетект.
Архитектурное решение: **сессия никогда не восстанавливается** — после разрыва TUI не пытается определить, вернулась ли та же плата, просто стартует диагностику с нуля.
---
## AppFrame — общий каркас экранов
`widgets/app_frame.py` — единый контейнер, который оборачивает содержимое всех трёх основных экранов:
```css
AppFrame {
width: 100%;
height: 100%;
max-width: 160;
max-height: 50;
border: heavy $primary;
}
```
Решает две задачи:
1. **Визуальная консистентность** — одна и та же рамка на всех экранах.
2. **Устраняет краш Textual 8.x** при mouse drag (`assert isinstance(content_widget.parent, Widget)`) — раньше `Screen` мог выступать `content_widget` напрямую; с `AppFrame` между `Screen` и контентом всегда есть валидный промежуточный `Widget`.
Адаптивный размер (`100%` с потолком `160×50`) — гарантирует, что элементы управления (кнопки, таблицы) никогда не обрезаются на маленьком терминале и не расползаются на огромном мониторе.
---
## Архитектура экранов
```mermaid
graph TB
subgraph ServiceApp["ServiceApp (app.py)"]
direction LR
WS["WaitingScreen"]
FS["FlashScreen"]
PF["PostFlashScreen"]
DS["DiagScreen"]
end
subgraph DiagInternals["DiagScreen (screens/diag/)"]
TL["TestListPanel\ntest_list.py"]
RP["ResultsPanel (DataTable)\nresults.py"]
CP["ConfirmPanel\nconfirm_panel.py"]
OR["Orchestrator\norchestrator.py"]
end
subgraph Clients["Клиенты"]
FC["FirmwareClient"]
M5["M5Client"]
FL["Flasher"]
end
WS -->|"DeviceDetected(FLASHING)"| FS
WS -->|"DeviceDetected(DIAGNOSING)"| DS
FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF
FS -->|"FlashDone(остальное)"| WS
PF -->|"Done"| WS
DS -->|"DiagDone(reason)"| WS
FS --> FL
DS --> OR
DS --> TL
DS --> RP
DS --> CP
CP -->|"Confirmed"| DS
OR --> FC
OR --> M5
WS --> FL
FS -.->|"ConnectionWatcherMixin"| FL
DS -.->|"ConnectionWatcherMixin"| FL
```
---
## Жизненный цикл диагностической сессии
```mermaid
sequenceDiagram
participant OP as Оператор
participant TUI as ServiceApp
participant WS as WaitingScreen
participant DS as DiagScreen
participant FW as firmware_test
participant M5 as M5StampPLC
OP->>TUI: запустить service_tui
TUI->>WS: push_screen()
WS->>WS: pyusb poll каждые 1.5 с
OP->>FW: подключить плату USB
WS->>TUI: DeviceDetected(DIAGNOSING)
TUI->>FW: auto_connect() → ping→pong
TUI->>FW: get_version()
TUI->>M5: auto_connect() (опционально)
TUI->>DS: switch_screen(fw_version=...)
DS->>FW: list_tests() → TestInfo×N
DS->>FW: get_uid()
DS->>DS: populate (тесты НЕ выбраны по умолчанию)
DS->>DS: ConnectionWatcherMixin: старт мониторинга
OP->>DS: выбрать тесты / "Выбрать все" → Запустить
DS->>FW: run_selected([...])
loop Для каждого теста
FW-->>DS: test_begin
DS->>DS: ResultsPanel.set_running()
opt progress (напр. usd)
FW-->>DS: progress {step, status}
DS->>DS: обновить прогресс-строку
end
alt HIL confirm (opto / can)
FW-->>DS: confirm_request
DS->>M5: relay_set() / can_send() / can_recv()
DS->>FW: send_confirm(true/false)
else Оператор (display / mqs)
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_operator()
OP->>DS: OK / Нет
DS->>FW: send_confirm(true/false)
else Кнопки
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_buttons_hint()
OP->>FW: физическое нажатие
end
FW-->>DS: test_result
DS->>DS: ResultsPanel.set_result() + сортировка FAIL-наверх
end
FW-->>DS: summary
DS->>DS: показать итог PASS / FAIL
alt Нормальное завершение
OP->>DS: ESC / Выйти → DiagDone()
TUI->>WS: switch_screen()
else Потеря USB
DS->>DS: ConnectionLost
TUI->>FW: disconnect()
TUI->>WS: switch_screen(disconnect_reason=...)
end
```
---
## Конфигурация (`.env`)
```ini
# USB VID:PID — BootROM SDP (константы NXP, не менять)
BOOTROM_VID=1fc9
BOOTROM_PID=0130
# USB VID:PID — Flashloader (константы NXP, не менять)
FLASHLOADER_VID=15a2
FLASHLOADER_PID=0073
# USB VID:PID — firmware_test CDC (наше устройство)
SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad
# Тип сборки firmware_test для прошивки (Debug | Release).
# Release временно нестабилен — по умолчанию Debug.
FIRMWARE_BUILD_TYPE=Debug
# Опционально: путь к директории лога TUI
# SERVICE_LOG_DIR=/tmp
```
---
## Версионирование firmware
`firmware_test` версионируется через CMake (`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через `configure_file`. Команда протокола `get_version` (по аналогии с `get_uid`) запрашивается один раз при подключении в `ServiceApp._connect_and_diagnose()` и передаётся в `DiagScreen` параметром конструктора — версия не запрашивается повторно внутри самого экрана.
---
## Запуск
### Из монорепозитория (разработчик)
```bash
just host::service-setup # установить зависимости tools/production/
just host::service-tui # запустить TUI
```
### Standalone-бинарь (сервисник)
```bash
just host::service-build
# → tools/production/dist/service_tui
```
> Standalone-бинарь не включает `tools/host/` — для прошивки рядом нужен инициализированный `tools/host/` (`just host::setup-tools`), либо абсолютный путь в `_FLASH_USB_SCRIPT` (`flasher.py`).
Что важно знать:
- **Тесты изначально не выбраны** — выбирайте вручную либо кнопками "Выбрать
все"/"Снять все".
- **Результаты** — таблица с сортировкой FAIL-наверх, FAIL-строка подсвечена
целиком, длинные сообщения переносятся на несколько строк без обрезания.
Колонка `M5 Bench` отмечает `[*]`/`[-]` — требует ли тест HIL-стенд.
- **HIL-тесты** (CAN, оптовходы) без подключённого M5StampPLC — серые,
недоступны для выбора.
- **Интерактивные проверки во время прогона теста** бывают трёх видов:
- HIL-тесты (CAN, оптовходы) при наличии M5 — отвечает стенд автоматически,
без участия оператора;
- тесты кнопок — экран показывает подсказку `⌨ <инструкция>`, ответ не
требуется, нужно физически нажать кнопку на плате;
- остальное (дисплей, аудио) — форма "Да/Нет" с таймером `⚠ <вопрос>` на
экране.
- До завершения загрузки списка тестов правая колонка показывает подсказку
«Выберите тесты и нажмите «Запустить выбранные»» вместо таблицы.
---
@ -428,7 +140,7 @@ just host::service-build
```bash
1. BOOT_MOD_1 → 3V3, сбросить плату → FlashScreen
2. Выбрать firmware_test → Прошить
2. Выбрать «Диагностическая прошивка (firmware_test)» → Загрузить
3. PostFlashScreen: BOOT_MOD_1 → GND, сбросить плату
4. Нажать "Готово" (или дождаться авто-перехода через 40с)
5. TUI автоматически попадает в DiagScreen при следующем подключении
@ -438,12 +150,63 @@ just host::service-build
```bash
1. Плата в SDP-режиме (BOOT_MOD_1 → 3V3)
2. FlashScreen → Chip Erase (~30 с)
2. FlashScreen → Очистить память (~30 с)
3. После erase BootROM не загрузит прошивку — требуется перепрошить
```
---
## Конфигурация (`.env`)
```ini
# USB VID:PID — BootROM SDP (константы NXP, не менять)
BOOTROM_VID=1fc9
BOOTROM_PID=0130
# USB VID:PID — Flashloader (константы NXP, не менять)
FLASHLOADER_VID=15a2
FLASHLOADER_PID=0073
# USB VID:PID — firmware_test CDC (наше устройство)
SERVICE_CDC_VID=1996
SERVICE_CDC_PID=00ad
# USB VID:PID — M5StampPLC (детект HIL-стенда)
SERVICE_M5_VID=303a
SERVICE_M5_PID=4001
# Тип сборки firmware_test для прошивки (Debug | Release).
# Release временно нестабилен — по умолчанию Debug.
FIRMWARE_BUILD_TYPE=Debug
# Опционально: путь к директории лога TUI
# SERVICE_LOG_DIR=/tmp
```
---
## Запуск
### Из монорепозитория (разработчик)
```bash
just host::service-setup # установить зависимости tools/production/
just host::service-tui # запустить TUI
```
### Standalone-бинарь (сервисник)
```bash
just host::service-build
# → tools/production/dist/service_tui
```
> Standalone-бинарь не включает `tools/host/` — для прошивки рядом нужен
> инициализированный `tools/host/` (`just host::setup-tools`), либо
> абсолютный путь в `_FLASH_USB_SCRIPT` (`flasher.py`).
---
## Зависимости
| Пакет | Версия | Назначение |
@ -454,21 +217,9 @@ just host::service-build
| `python-dotenv` | ≥ 1.0 | загрузка `.env` |
| `pyinstaller` | ≥ 6.0 | сборка standalone-бинаря |
**Runtime-зависимость (не в `pyproject.toml`):** `flasher.py` вызывает `tools/host/flash_usb.py` через `uv run``tools/host/` должен быть инициализирован (`just host::setup-tools`).
---
## Известные грабли Textual 8.x (для тех, кто продолжит разработку)
Зафиксировано на практике — экономит время при будущих доработках:
- **`Screen.Message` не существует.** Вложенные сообщения экранов наследуются от `textual.message.Message` напрямую, не от несуществующего атрибута `Screen.Message`.
- **`self._running` — зарезервированное имя.** `MessagePump` (предок `Screen`) использует это поле для своего внутреннего message loop. Случайное совпадение имени тихо ломает логику без исключения — в `DiagScreen` переименовано в `_tests_running`.
- **`row.mount(child)` сразу после `self.mount(row)` бросает `MountError`** — `row` ещё не прикреплён к DOM. Решение: передавать детей в конструктор контейнера (`Horizontal(cb, label, classes=...)`) и монтировать одним `mount_all()`.
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня проекта — постоянно расходится при рефакторинге структуры. Решение: один `CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает ширину по умолчанию равную длине заголовка — длинный контент обрезается независимо от `height` строки. Нужно использовать `add_column(label, width=N)` по одной колонке.
- **`DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж значений ячеек** (для указанных `columns`), не `row_key` и не `(row_key, row_data)`. Сортировка по `test_id` напрямую невозможна без парсинга содержимого ячеек, которые сами полностью контролируем.
- **Нет публичного API для изменения высоты уже добавленной строки.** `update_cell()` меняет только содержимое. Если нужно изменить `height` (например, под более длинный текст) — единственный надёжный путь: `remove_row()` + `add_row(..., height=N)`.
**Runtime-зависимость (не в `pyproject.toml`):** `flasher.py` вызывает
`tools/host/flash_usb.py` через `uv run``tools/host/` должен быть
инициализирован (`just host::setup-tools`).
---
@ -479,4 +230,5 @@ tools/production/service_tui.log ← по умолчанию
$SERVICE_LOG_DIR/service_tui.log ← если задан в .env
```
Уровень: `DEBUG` для модулей приложения, `WARNING` для самого textual. TUI не пишет в stdout — Textual захватывает терминал.
Уровень: `DEBUG` для модулей приложения, `WARNING` для самого textual. TUI не
пишет в stdout — Textual захватывает терминал.

View file

@ -0,0 +1,453 @@
# service-tui — техническая архитектура
> Компонент: `tools/production/` — TUI сервисного инженера (диагностика и
> прошивка платы MIMXRT1052CVJ5B).
> Документ описывает внутреннее устройство: структуру модулей, протокол
> взаимодействия с firmware/M5, экранную архитектуру Textual, известные
> особенности фреймворка.
> Пользовательская документация (экраны, запуск, конфигурация,
> рабочие процессы сервисника) — в [README.md](README.md).
---
## 1. Структура проекта
```bash
tools/production/
├── main.py ← точка входа (10 строк)
├── pyproject.toml ← зависимости uv
├── uv.lock
└── app/
├── app.py ← ServiceApp — роутинг экранов, жизненный цикл клиентов
├── app.tcss ← единый файл стилей для всех экранов
├── models.py ← все типы данных (dataclass/Enum)
├── boot_art.py ← LOGO_ART — растеризованный логотип для WaitingScreen
├── firmware_client.py ← async USB CDC клиент firmware_test (UTF-8)
├── m5_client.py ← async M5StampPLC клиент (Serial JSON-lines, UTF-8)
├── flasher.py ← subprocess-обёртка над tools/host/flash_usb.py
├── orchestrator.py ← маршрутизация confirm_request, progress, таймауты
├── widgets/
│ ├── __init__.py
│ └── app_frame.py ← AppFrame — общий адаптивный контейнер всех экранов
└── screens/
├── __init__.py ← реэкспорт: WaitingScreen, FlashScreen, PostFlashScreen, DiagScreen
├── waiting.py ← WaitingScreen — ожидание USB, лого, версия, баннер причины возврата
├── flash.py ← FlashScreen — прошивка / chip erase
├── post_flash.py ← PostFlashScreen — промпт смены BootMode после прошивки
├── connection_watcher.py ← ConnectionWatcherMixin — мониторинг обрыва USB
└── diag/
├── __init__.py ← DiagScreen — координатор диагностики
├── test_list.py ← TestListPanel — чекбоксы тестов, Выбрать/Снять все
├── results.py ← ResultsPanel — DataTable результатов
└── confirm_panel.py ← ConfirmPanel — prompt оператора + countdown
```
---
## 2. Концепция
```mermaid
graph LR
subgraph PC["Сервисный ПК"]
TUI["service-tui\n(Textual App)"]
subgraph app["app/"]
FC["firmware_client.py\nUSB CDC ACM, UTF-8"]
M5["m5_client.py\nSerial JSON-lines, UTF-8"]
FL["flasher.py\nsubprocess + pyusb detect"]
OR["orchestrator.py\nconfirm/progress/timeout router"]
end
TUI --> FC & M5 & FL & OR
end
subgraph Board["Плата TFT (MIMXRT1052)"]
FW["firmware_test\n(USB CDC)"]
ROM["BootROM SDP\n(1FC9:0130)"]
end
subgraph HIL["HIL стенд (опционально)"]
M5HW["M5StampPLC\nRLY14 + CAN"]
end
subgraph Host["tools/host/"]
FU["flash_usb.py\nsdphost + blhost"]
end
FC <-->|"JSON-lines UTF-8\nVID:PID 1996:00AD"| FW
FL -->|"subprocess uv run"| FU
FU -->|"sdphost + blhost\nVID:PID 1FC9:0130"| ROM
M5 <-->|"JSON-lines\nSerial"| M5HW
M5HW -->|"RLY14"| Board
```
> **Детект USB:** `Flasher.detect_sdp()`/`detect_cdc()` используют `pyusb` как
> основной метод (BootROM SDP не создаёт serial-порт на macOS и невидим через
> `pyserial.list_ports`), с fallback на `serial.tools.list_ports` для CDC.
> M5StampPLC детектируется отдельно в `m5_client.py` тем же способом
> (`pyusb`, VID/PID из `.env` — см. раздел 5).
---
## 3. Диаграмма состояний приложения
Состояние определяется автодетектом USB и меняется динамически без
перезапуска TUI. При потере соединения сессия разрывается полностью — TUI не
пытается восстановить прежнее состояние, а стартует заново с `WaitingScreen`.
```mermaid
stateDiagram-v2
[*] --> WAITING : запуск TUI
WAITING --> FLASHING : VID:PID 1FC9:0130\n(BootROM SDP)
WAITING --> DIAGNOSING : VID:PID 1996:00AD\n+ ping→pong по CDC
FLASHING --> POST_FLASH : firmware_test прошит успешно
FLASHING --> WAITING : Production/Custom прошит, ошибка,\nили потеря USB в простое
POST_FLASH --> WAITING : оператор подтвердил / таймаут 40с
DIAGNOSING --> WAITING : DiagDone / ESC /\nпотеря USB в простое
DIAGNOSING --> FLASHING: плата переведена в SDP (перемычка BOOT_MOD)
```
Состояния соответствуют `AppMode` в `models.py`; переключение экранов —
`ServiceApp.push_screen()`/`switch_screen()` в `app.py`, реагирующий на
сообщения `DeviceDetected`/`FlashDone`/`DiagDone`.
---
## 4. Обработка confirm_request
Маршрутизация реализована в `Orchestrator._handle_confirm()` по значению
`confirm_request.id`. Помимо confirm, протокол v2 определяет `progress`
внутришаговые информационные события долгих тестов (сейчас только `usd`), не
требующие ответа.
```mermaid
flowchart TD
EV["Событие от firmware_test"]
EV --> T{type}
T -->|"confirm_request"| R{confirm_request.id}
T -->|"progress"| PROG["TEST_PROGRESS\nотобразить фазу в прогресс-строке"]
T -->|"test_begin / test_result / summary"| STD["стандартная обработка"]
T -->|"_timeout (синтетическое,\nот FirmwareClient)"| TO["синтезировать FAIL\nдля зависшего теста\n+ гарантированный SUMMARY"]
T -->|"неизвестный тип"| LOG["logger.debug — НЕ ошибка,\nне показывается оператору"]
R -->|"opto_*"| HIL_OPTO["M5: relay_set → settle → send_confirm"]
R -->|"can_rx_ready"| HIL_CAN_RX["M5: can_send → send_confirm"]
R -->|"can_tx_verify"| HIL_CAN_TX["M5: can_recv → verify → send_confirm"]
R -->|"btn*"| BTN["show_buttons_hint, БЕЗ JSON-confirm"]
R -->|"остальное"| OP["show_operator + countdown\nждать resolve_operator_confirm()"]
```
| `confirm_request.id` | Кто отвечает | Реле M5 |
| ------------------------------- | ---------------------------- | ------- |
| `opto_in1_active` / `_inactive` | M5 авто | RLY3 |
| `opto_in2_active` / `_inactive` | M5 авто | RLY4 |
| `opto_rs_active` / `_inactive` | M5 авто | RLY2 |
| `can_rx_ready` | M5 авто (CAN TX) | — |
| `can_tx_verify` | M5 авто (CAN RX) | — |
| `btn*` | физика, без JSON-ответа | — |
| всё остальное | оператор, prompt + countdown | — |
**Гарантия таймаута:** `Orchestrator.run_tests()` всегда завершается ровно
одним событием `SUMMARY` — настоящим от firmware или синтетическим
(`aborted: true`), если чтение порта оборвалось по таймауту. Без этой гарантии
зависший тест блокировал бы кнопки "Выйти" и повторного запуска навсегда
(исторический баг, см. `CHANGELOG.md`).
---
## 5. Протокол M5 agent (JSON-lines)
`m5_client.py` общается с `tools/hil/m5/agent.py` напрямую (не через
`tools/hil/` pytest-окружение — у HIL pytest свой собственный путь: порт берёт
из `.env` (`HIL_M5_PORT`) и не использует `M5Client`).
Актуальный формат ответа агента:
```json
{"ok": true, "id": 1, "data": [...]}
```
Значимые детали, зафиксированные по факту сверки с `agent.py` (`grep` по
обработчикам команд):
- Поле успеха — **`ok`** (bool), не `status`. Относится ко всем командам:
`ping`, `relay_set`, `relay_get`, `can_send`, `can_recv`.
- Ключ канала реле в `relay_set`/`relay_get` — **`ch`**, не `relay`.
- VID/PID детекта M5StampPLC настраиваются через `.env`:
`SERVICE_M5_VID`/`SERVICE_M5_PID` (см. README, раздел «Конфигурация»).
Рантайм-режим агента (MicroPython) отличается от ROM-режима ESP32-S3 по
PID — при детекте ориентироваться на `just host::m5-scan`, а не на
документацию, если она когда-либо разойдётся с кодом.
**Важно на будущее:** документация (`HIL_BENCH.md`/`HIL_HOW_TO.md`) местами не
успевает за изменениями `agent.py`. При любых будущих изменениях протокола
агента (новые команды, смена формата ответа) — сверяться напрямую через
`grep` по `tools/hil/m5/agent.py`, а не полагаться только на документацию.
---
## 6. Мониторинг соединения и разрыв сессии
`ConnectionWatcherMixin` (`screens/connection_watcher.py`) подключается к
`FlashScreen` и `DiagScreen`: каждые 1.5с проверяет, виден ли таргет на шине.
- **На `FlashScreen`** — проверка приостановлена во время активной
прошивки/erase (обрыв обнаружит сам `flash_usb.py` subprocess).
- **На `DiagScreen`** — проверка приостановлена во время прогона тестов
(обрыв надёжнее детектирует таймаут чтения порта внутри `Orchestrator`, не
просто исчезновение устройства из списка).
- При срабатывании — `ConnectionLost` message → экран постит
`FlashDone(success=False, target=None)` / `DiagDone(reason=...)`
`ServiceApp` разрывает сессию (`FirmwareClient.disconnect()`) и переключает
на `WaitingScreen(disconnect_reason=...)`.
- `WaitingScreen` показывает причину возврата баннером на 4 секунды, затем
продолжает обычный автодетект.
Архитектурное решение: **сессия никогда не восстанавливается** — после
разрыва TUI не пытается определить, вернулась ли та же плата, просто стартует
диагностику с нуля.
---
## 7. AppFrame — общий каркас экранов
`widgets/app_frame.py` — единый контейнер, который оборачивает содержимое
всех трёх основных экранов:
```css
AppFrame {
width: 100%;
height: 100%;
max-width: 112;
max-height: 35;
border: heavy $primary;
}
```
Решает две задачи:
1. **Визуальная консистентность** — одна и та же рамка на всех экранах.
2. **Устраняет краш Textual 8.x** при mouse drag
(`assert isinstance(content_widget.parent, Widget)`) — раньше `Screen` мог
выступать `content_widget` напрямую; с `AppFrame` между `Screen` и
контентом всегда есть валидный промежуточный `Widget`.
Адаптивный размер (`100%` с потолком `112×35`) — гарантирует, что элементы
управления (кнопки, таблицы) никогда не обрезаются на маленьком терминале и
не расползаются на огромном мониторе. Потолок подобран и подтверждён
визуально на скриншотах всех пяти экранов; нижняя граница по ширине
обоснована жёстко: `TestListPanel(width:38)` + `ResultsPanel(width:70)` рядом
на `DiagScreen` дают 108 + рамка = 110 — меньше сжимать уже нельзя.
---
## 8. Архитектура экранов
```mermaid
graph TB
subgraph ServiceApp["ServiceApp (app.py)"]
direction LR
WS["WaitingScreen"]
FS["FlashScreen"]
PF["PostFlashScreen"]
DS["DiagScreen"]
end
subgraph DiagInternals["DiagScreen (screens/diag/)"]
TL["TestListPanel\ntest_list.py"]
RP["ResultsPanel (DataTable)\nresults.py"]
CP["ConfirmPanel\nconfirm_panel.py"]
OR["Orchestrator\norchestrator.py"]
end
subgraph Clients["Клиенты"]
FC["FirmwareClient"]
M5["M5Client"]
FL["Flasher"]
end
WS -->|"DeviceDetected(FLASHING)"| FS
WS -->|"DeviceDetected(DIAGNOSING)"| DS
FS -->|"FlashDone(success=True, target=FIRMWARE_TEST)"| PF
FS -->|"FlashDone(остальное)"| WS
PF -->|"Done"| WS
DS -->|"DiagDone(reason)"| WS
FS --> FL
DS --> OR
DS --> TL
DS --> RP
DS --> CP
CP -->|"Confirmed"| DS
OR --> FC
OR --> M5
WS --> FL
FS -.->|"ConnectionWatcherMixin"| FL
DS -.->|"ConnectionWatcherMixin"| FL
```
---
## 9. Жизненный цикл диагностической сессии
```mermaid
sequenceDiagram
participant OP as Оператор
participant TUI as ServiceApp
participant WS as WaitingScreen
participant DS as DiagScreen
participant FW as firmware_test
participant M5 as M5StampPLC
OP->>TUI: запустить service_tui
TUI->>WS: push_screen()
WS->>WS: pyusb poll каждые 1.5 с
OP->>FW: подключить плату USB
WS->>TUI: DeviceDetected(DIAGNOSING)
TUI->>FW: auto_connect() → ping→pong
TUI->>FW: get_version()
TUI->>M5: auto_connect() (опционально)
TUI->>DS: switch_screen(fw_version=...)
DS->>FW: list_tests() → TestInfo×N
DS->>FW: get_uid()
DS->>DS: populate (тесты НЕ выбраны по умолчанию)
DS->>DS: ConnectionWatcherMixin: старт мониторинга
OP->>DS: выбрать тесты / "Выбрать все" → Запустить
DS->>FW: run_selected([...])
loop Для каждого теста
FW-->>DS: test_begin
DS->>DS: ResultsPanel.set_running()
opt progress (напр. usd)
FW-->>DS: progress {step, status}
DS->>DS: обновить прогресс-строку
end
alt HIL confirm (opto / can)
FW-->>DS: confirm_request
DS->>M5: relay_set() / can_send() / can_recv()
DS->>FW: send_confirm(true/false)
else Оператор (display / mqs)
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_operator()
OP->>DS: OK / Нет
DS->>FW: send_confirm(true/false)
else Кнопки
FW-->>DS: confirm_request
DS->>DS: ConfirmPanel.show_buttons_hint()
OP->>FW: физическое нажатие
end
FW-->>DS: test_result
DS->>DS: ResultsPanel.set_result() + сортировка FAIL-наверх
end
FW-->>DS: summary
DS->>DS: показать итог PASS / FAIL
alt Нормальное завершение
OP->>DS: ESC / Выйти → DiagDone()
TUI->>WS: switch_screen()
else Потеря USB
DS->>DS: ConnectionLost
TUI->>FW: disconnect()
TUI->>WS: switch_screen(disconnect_reason=...)
end
```
---
## 10. Версионирование firmware
`firmware_test` версионируется через CMake
(`project(firmware_test VERSION X.Y.Z)`), генерирует `version.h` через
`configure_file`. Команда протокола `get_version` (по аналогии с `get_uid`)
запрашивается один раз при подключении в `ServiceApp._connect_and_diagnose()`
и передаётся в `DiagScreen` параметром конструктора — версия не запрашивается
повторно внутри самого экрана.
Версия самого TUI (`service_tool vX.Y.Z` на `WaitingScreen`) читается
отдельно — `waiting.py::_read_app_version()` парсит `[project].version` из
`pyproject.toml` напрямую через `tomllib` (stdlib). `importlib.metadata`
сознательно не используется — проект не ставится как пакет
(`tool.uv.package = false`), метаданных может не быть.
---
## 11. Логотип (`boot_art.py`)
`LOGO_ART` — Rich-markup строка (29×21 символов, цвета `#3ca0dc` для синей
части логотипа, `white` для тёмной, `grey37` для фоновых точек), полученная
одноразовой coverage-based растеризацией `logo.png` (300×300 RGBA) через
Pillow: разбор на сетку символов с компенсацией аспекта шрифта терминала
(`ASPECT = 0.5`), классификация фона/двух цветовых групп логотипа по каналам,
плотность символа на ячейку — по доле непрозрачных пикселей
(`.::+*#`/`.::+%@`).
Сам скрипт растеризации **не сохранён в репозитории** (использовался
разово в песочнице, не входит в `pyproject.toml` TUI — новых
runtime-зависимостей `boot_art.py` не добавляет). Если логотип компании
сменится — скрипт нужно будет написать заново; логика воспроизводима (см.
абзац выше).
---
## 12. Известные грабли Textual 8.x
Зафиксировано на практике — экономит время при будущих доработках:
- **`Screen.Message` не существует.** Вложенные сообщения экранов
наследуются от `textual.message.Message` напрямую, не от несуществующего
атрибута `Screen.Message`.
- **`self._running` — зарезервированное имя.** `MessagePump` (предок
`Screen`) использует это поле для своего внутреннего message loop.
Случайное совпадение имени тихо ломает логику без исключения — в
`DiagScreen` переименовано в `_tests_running`.
- **`row.mount(child)` сразу после `self.mount(row)` бросает `MountError`** —
`row` ещё не прикреплён к DOM. Решение: передавать детей в конструктор
контейнера (`Horizontal(cb, label, classes=...)`) и монтировать одним
`mount_all()`.
- **`CSS_PATH` резолвится относительно файла класса**, не относительно корня
проекта — постоянно расходится при рефакторинге структуры. Решение: один
`CSS_PATH` только на `ServiceApp`, все стили в едином `app.tcss`.
- **`table.add_columns(*labels)` не принимает `width=`.** Колонка получает
ширину по умолчанию равную длине заголовка — длинный контент обрезается
независимо от `height` строки. Нужно использовать `add_column(label,
width=N)` по одной колонке.
- **`DataTable.sort(*columns, key=fn)` передаёт в `key()` кортеж значений
ячеек** (для указанных `columns`), не `row_key` и не `(row_key, row_data)`.
Сортировка по `test_id` напрямую невозможна без парсинга содержимого ячеек,
которые сами полностью контролируем.
- **Нет публичного API для изменения высоты уже добавленной строки.**
`update_cell()` меняет только содержимое. Если нужно изменить `height`
(например, под более длинный текст) — единственный надёжный путь:
`remove_row()` + `add_row(..., height=N)`.
- **Центровка текста внутри full-width виджета не решается `Center()`.**
`Static`/`Label` без `text-align`, растянутый на всю ширину родителя,
прижимает текст к левому краю — `Center()` вокруг такого виджета не
помогает (центрировать нечего, ребёнок и так 100% ширины). Нужен
`text-align: center` в CSS на самом элементе. Обратный случай — виджет с
"естественной" (auto) шириной — центрируется именно через `Center()`.
Важно не путать эти два случая (`#waiting-version` — первый случай,
`#post-flash-title`/`#post-flash-instruction` — второй).
---
## Известные открытые вопросы
- **Release-сборка firmware нестабильна** (медленное мигание — подозрение на
проблему с FCB/clock конфигурацией в Release HAB-образе) — TUI временно
форсирует Debug через `FIRMWARE_BUILD_TYPE`.
- **`tools/shared/m5_agent.py`** — сознательно не делался: pytest
HIL-окружение и TUI используют независимые M5-клиенты, признано правильным
архитектурным решением, а не техдолгом.
- Пункты плана TUI «экспорт результатов в JSON с привязкой к UID» и
«копирование UID с экрана» — отложены, не начаты.

File diff suppressed because one or more lines are too long

View file

@ -1,8 +0,0 @@
# TUI Service Tools замечания №2
1. Тест usd проводится только один раз, при втором прогоне зависает намертво (возможно usdhc стек не деинициализируется корректно)
2. При выполнении теста psd в прогресс баре ошибка - unknown event progress
3. Нужно вообще убрать проценты в статус баре (при прохождении тестов) - лишняя история
4. Fallback при потере USB соединения в Flasher и Testing screen - возможно ли отображать статус поключения к таргету? (SDP, firmware_test) и есть статус отключен не давать взаимодействовать с элементами экранов (кроме выйти)
5. После таймаута по выполнению теста (recv_until timeout after 120s) нет никакой индикации о статусе теста в таблице (от по факту завален) + после этого не получается выйти из приложения с помощью кнопки и не получается запустить ни один тест
6. В таблице тестов в столбце Детали при неудачном выполнение теста обрезается часть информации

View file

@ -0,0 +1,163 @@
# Отчёт: сессия доработки 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 этого отчёта, воспроизводима).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB