# firmware_test: TUI app current state
This commit is contained in:
parent
0b16f5cf19
commit
e2f00eca99
23 changed files with 1005 additions and 1912 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,6 +59,7 @@ build*
|
|||
Testing/
|
||||
!docs/testing
|
||||
!build.just
|
||||
service_tui.log
|
||||
|
||||
# Файлы настройки среды разработки
|
||||
.project
|
||||
|
|
|
|||
|
|
@ -59,8 +59,16 @@ class ServiceApp(App):
|
|||
"""
|
||||
После прошивки:
|
||||
- firmware_test + успех → PostFlashScreen (промпт смены BootMode)
|
||||
- всё остальное (production/custom, либо ошибка) → сразу WaitingScreen
|
||||
- всё остальное (production/custom, либо ошибка) → WaitingScreen
|
||||
- target=None — маркер потери соединения (см. ConnectionWatcherMixin)
|
||||
→ WaitingScreen с явной причиной возврата
|
||||
"""
|
||||
if event.target is None and not event.success:
|
||||
self.switch_screen(
|
||||
WaitingScreen(disconnect_reason="Соединение с платой потеряно")
|
||||
)
|
||||
return
|
||||
|
||||
if event.success and event.target == FlashTarget.FIRMWARE_TEST:
|
||||
self.switch_screen(PostFlashScreen())
|
||||
else:
|
||||
|
|
@ -72,10 +80,10 @@ class ServiceApp(App):
|
|||
self.switch_screen(WaitingScreen())
|
||||
|
||||
@on(DiagScreen.DiagDone)
|
||||
def _on_diag_done(self) -> None:
|
||||
def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
|
||||
"""После диагностики — отключиться, вернуться в Waiting."""
|
||||
self._disconnect()
|
||||
self.switch_screen(WaitingScreen())
|
||||
self.switch_screen(WaitingScreen(disconnect_reason=event.reason))
|
||||
|
||||
# ── Подключение к firmware_test ───────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,19 @@ AppFrame {
|
|||
text-style: bold;
|
||||
}
|
||||
|
||||
#waiting-reason {
|
||||
margin-top: 1;
|
||||
padding: 1 2;
|
||||
border: round $warning;
|
||||
color: $warning;
|
||||
text-style: bold;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#waiting-reason.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#waiting-hint {
|
||||
margin-top: 2;
|
||||
color: $text-muted;
|
||||
|
|
|
|||
|
|
@ -159,6 +159,12 @@ class FirmwareClient:
|
|||
"""
|
||||
Читать события до получения одного из stop_types или таймаута.
|
||||
Генератор — yield каждого полученного события.
|
||||
|
||||
При таймауте yield-ит синтетическое событие
|
||||
{"type": "_timeout", "timeout_s": ...} перед завершением — вызывающий
|
||||
код (Orchestrator) должен явно обработать этот тип и не путать его
|
||||
с обрывом связи без объяснения причины. Префикс "_" отличает это
|
||||
от реальных событий протокола firmware_test.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_s
|
||||
|
|
@ -171,6 +177,7 @@ class FirmwareClient:
|
|||
if event.get("type") in stop_types:
|
||||
return
|
||||
logger.warning("_recv_until timeout after %.1f s", timeout_s)
|
||||
yield {"type": "_timeout", "timeout_s": timeout_s}
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ orchestrator.py — оркестратор confirm_request для TUI.
|
|||
"btn*" → buttons (нет confirm, ждём test_result)
|
||||
всё остальное → operator (показать prompt)
|
||||
|
||||
Помимо confirm_request, протокол v2 определяет "progress" — внутришаговые
|
||||
информационные события долгих тестов (сейчас только usd: card_detect,
|
||||
mount, write, read_compare — см. docs/testing/PROTOCOL.md). Эти события
|
||||
не требуют ответа, только отображаются как текущая фаза прогресса.
|
||||
|
||||
Публичный API:
|
||||
Orchestrator.run_tests(test_ids) — запустить тесты, yield OrchestratorEvent
|
||||
"""
|
||||
|
|
@ -67,6 +72,9 @@ _CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
|
|||
_CAN_TX_ID = 0x200
|
||||
_CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE]
|
||||
|
||||
# Тип события от FirmwareClient._recv_until() сигнализирующий таймаут чтения
|
||||
_TIMEOUT_EVENT_TYPE = "_timeout"
|
||||
|
||||
|
||||
# ── Типы событий оркестратора ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -74,11 +82,12 @@ _CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE]
|
|||
class OrchestratorEventType(Enum):
|
||||
TEST_BEGIN = auto() # тест начался
|
||||
TEST_RESULT = auto() # тест завершился
|
||||
TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.)
|
||||
CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone)
|
||||
CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически
|
||||
BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm)
|
||||
SUMMARY = auto() # итог всей сессии
|
||||
ERROR = auto() # ошибка протокола или M5
|
||||
ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -137,14 +146,24 @@ class Orchestrator:
|
|||
|
||||
Yields OrchestratorEvent в порядке поступления событий от firmware_test.
|
||||
Блокируется на CONFIRM_NEEDED до вызова resolve_operator_confirm().
|
||||
|
||||
Гарантия для вызывающего кода: генератор ВСЕГДА заканчивается событием
|
||||
SUMMARY — либо настоящим (от firmware), либо синтетическим при обрыве
|
||||
потока (таймаут чтения порта истёк раньше, чем пришёл summary).
|
||||
Без этой гарантии TUI не может надёжно понять, что прогон завершился
|
||||
(именно это вызывало зависание кнопок при таймауте — см. отчёт).
|
||||
"""
|
||||
current_test_id = ""
|
||||
got_summary = False
|
||||
|
||||
async for raw in self._fw.run_selected(test_ids):
|
||||
event_type = raw.get("type", "")
|
||||
|
||||
if event_type == "test_begin":
|
||||
current_test_id = raw.get("id", "")
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.TEST_BEGIN,
|
||||
test_id=raw.get("id", ""),
|
||||
test_id=current_test_id,
|
||||
test_name=raw.get("name", ""),
|
||||
)
|
||||
|
||||
|
|
@ -161,12 +180,24 @@ class Orchestrator:
|
|||
duration_ms=raw.get("ms", 0),
|
||||
detail=raw.get("detail", ""),
|
||||
)
|
||||
current_test_id = ""
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.TEST_RESULT,
|
||||
test_id=result.id,
|
||||
result=result,
|
||||
)
|
||||
|
||||
elif event_type == "progress":
|
||||
# Внутришаговый прогресс долгого теста (сейчас только usd).
|
||||
# Не ошибка — информационное событие, не требует ответа.
|
||||
step = raw.get("step", "")
|
||||
pstat = raw.get("status", "")
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.TEST_PROGRESS,
|
||||
test_id=raw.get("test", current_test_id),
|
||||
message=f"{step}: {pstat}" if step else "progress",
|
||||
)
|
||||
|
||||
elif event_type == "confirm_request":
|
||||
confirm = ConfirmRequest(
|
||||
id=raw.get("id", ""),
|
||||
|
|
@ -177,15 +208,56 @@ class Orchestrator:
|
|||
yield ev
|
||||
|
||||
elif event_type == "summary":
|
||||
got_summary = True
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.SUMMARY,
|
||||
summary=raw,
|
||||
)
|
||||
|
||||
else:
|
||||
elif event_type == _TIMEOUT_EVENT_TYPE:
|
||||
# Чтение порта оборвалось по таймауту раньше, чем пришёл
|
||||
# summary. Завершаем текущий тест (если он был в RUNNING)
|
||||
# синтетическим FAIL, чтобы таблица результатов не осталась
|
||||
# с тестом, навечно подвисшим в "выполняется".
|
||||
timeout_s = raw.get("timeout_s", 0)
|
||||
logger.error(
|
||||
"Test run aborted: read timeout after %.0fs, current_test=%r",
|
||||
timeout_s,
|
||||
current_test_id,
|
||||
)
|
||||
if current_test_id:
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.TEST_RESULT,
|
||||
test_id=current_test_id,
|
||||
result=TestResult(
|
||||
id=current_test_id,
|
||||
status=TestStatus.FAIL,
|
||||
duration_ms=0,
|
||||
detail=f"таймаут связи ({timeout_s:.0f}с)",
|
||||
),
|
||||
)
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.ERROR,
|
||||
message=raw.get("error", f"unknown event: {event_type}"),
|
||||
message=f"Связь с платой прервана (таймаут {timeout_s:.0f}с)",
|
||||
)
|
||||
# break, не return — нужно дойти до synthetic summary ниже
|
||||
break
|
||||
|
||||
else:
|
||||
# Неизвестный, но не критичный тип события — логируем для
|
||||
# разработчика, не показываем оператору как ошибку (см.
|
||||
# отчёт замечание №2: ранее "progress" ошибочно считался
|
||||
# неизвестным событием до того как протокол был сверен).
|
||||
logger.debug("Unhandled protocol event: %r", raw)
|
||||
|
||||
if not got_summary:
|
||||
# Поток событий оборвался (таймаут или обрыв соединения) без
|
||||
# настоящего summary от firmware — синтезируем его, чтобы
|
||||
# вызывающий код (DiagScreen._run_worker) гарантированно вышел
|
||||
# из ожидания и разблокировал кнопки/чекбоксы.
|
||||
yield OrchestratorEvent(
|
||||
type=OrchestratorEventType.SUMMARY,
|
||||
summary={"overall": "fail", "passed": 0, "failed": 0, "aborted": True},
|
||||
)
|
||||
|
||||
# ── Маршрутизация confirm ────────────────────────────────────────────
|
||||
|
|
|
|||
78
tools/production/app/screens/connection_watcher.py
Normal file
78
tools/production/app/screens/connection_watcher.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
connection_watcher.py — мониторинг USB-соединения для экранов FlashScreen
|
||||
и DiagScreen.
|
||||
|
||||
Периодически (каждые _WATCH_INTERVAL_S) проверяет, виден ли таргет ещё
|
||||
на шине USB. При потере соединения — экран обязан немедленно прекратить
|
||||
любое взаимодействие (кроме кнопки "Выйти") и вернуться на WaitingScreen.
|
||||
Решение архитектурно: при разрыве сессия считается недостоверной и
|
||||
не восстанавливается — экран не пытается определить "вернулась ли та же
|
||||
самая плата", просто стартует заново с нуля.
|
||||
|
||||
Используется как миксин: класс экрана наследует ConnectionWatcherMixin
|
||||
вторым родителем после Screen, вызывает self._start_connection_watch(check_fn)
|
||||
в on_mount(), и переопределяет _on_connection_lost() для специфичной
|
||||
очистки экрана перед уходом на WaitingScreen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from textual.message import Message
|
||||
from textual.timer import Timer
|
||||
|
||||
_WATCH_INTERVAL_S = 1.5
|
||||
|
||||
|
||||
class ConnectionLost(Message):
|
||||
"""
|
||||
Соединение с платой потеряно во время нахождения на экране.
|
||||
Экран должен прекратить взаимодействие и вернуться на WaitingScreen.
|
||||
"""
|
||||
|
||||
|
||||
class ConnectionWatcherMixin:
|
||||
"""
|
||||
Миксин периодической проверки USB-соединения.
|
||||
|
||||
Использование в экране::
|
||||
|
||||
class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||
def on_mount(self) -> None:
|
||||
self._start_connection_watch(Flasher.detect_sdp)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._stop_connection_watch()
|
||||
"""
|
||||
|
||||
_connection_watch_timer: Timer | None = None
|
||||
_connection_lost: bool = False
|
||||
|
||||
def _start_connection_watch(self, check_fn: Callable[[], bool]) -> None:
|
||||
"""
|
||||
Запустить периодическую проверку. check_fn должна вернуть True
|
||||
пока устройство видно на шине (Flasher.detect_sdp / detect_cdc).
|
||||
"""
|
||||
self._connection_lost = False
|
||||
self._connection_check_fn = check_fn
|
||||
self._connection_watch_timer = self.set_interval( # type: ignore[attr-defined]
|
||||
_WATCH_INTERVAL_S, self._check_connection
|
||||
)
|
||||
|
||||
def _stop_connection_watch(self) -> None:
|
||||
if self._connection_watch_timer is not None:
|
||||
self._connection_watch_timer.stop()
|
||||
self._connection_watch_timer = None
|
||||
|
||||
def _check_connection(self) -> None:
|
||||
if self._connection_lost:
|
||||
return
|
||||
try:
|
||||
still_present = self._connection_check_fn()
|
||||
except Exception:
|
||||
still_present = False
|
||||
if not still_present:
|
||||
self._connection_lost = True
|
||||
self._stop_connection_watch()
|
||||
self.post_message(ConnectionLost()) # type: ignore[attr-defined]
|
||||
|
|
@ -7,6 +7,13 @@ DiagScreen координирует три виджета:
|
|||
ConfirmPanel — confirm_request оператора (нижняя панель)
|
||||
|
||||
и Orchestrator — маршрутизатор confirm_request.
|
||||
|
||||
Мониторинг соединения (см. отчёт, замечание №4): пока тесты не запущены,
|
||||
каждые 1.5с проверяется наличие CDC-порта на шине. Во время активного
|
||||
прогона мониторинг приостановлен — таймаут чтения порта внутри Orchestrator
|
||||
уже детектирует обрыв связи надёжнее (видит реальную остановку потока
|
||||
данных, а не просто исчезновение устройства из списка портов) и сам
|
||||
формирует понятный результат для прерванного теста.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -24,10 +31,12 @@ from textual.screen import Screen
|
|||
from textual.widgets import Button, Label, ProgressBar, Static
|
||||
|
||||
from ...firmware_client import FirmwareClient
|
||||
from ...flasher import Flasher
|
||||
from ...m5_client import M5Client
|
||||
from ...models import SessionState
|
||||
from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType
|
||||
from ...widgets import AppFrame
|
||||
from ..connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
||||
from .confirm_panel import ConfirmPanel
|
||||
from .results import ResultsPanel
|
||||
from .test_list import TestListPanel
|
||||
|
|
@ -35,7 +44,7 @@ from .test_list import TestListPanel
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiagScreen(Screen):
|
||||
class DiagScreen(Screen, ConnectionWatcherMixin):
|
||||
"""
|
||||
Экран диагностики.
|
||||
|
||||
|
|
@ -52,6 +61,10 @@ class DiagScreen(Screen):
|
|||
class DiagDone(Message):
|
||||
"""Сессия диагностики завершена."""
|
||||
|
||||
def __init__(self, reason: Optional[str] = None) -> None:
|
||||
super().__init__()
|
||||
self.reason = reason
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
firmware: FirmwareClient,
|
||||
|
|
@ -70,9 +83,9 @@ class DiagScreen(Screen):
|
|||
with AppFrame(id="diag-frame"):
|
||||
# Шапка — фиксированная высота 3
|
||||
with Horizontal(id="diag-header"):
|
||||
yield Static("firmware_test: —\nFw: —", id="diag-header-fw")
|
||||
yield Static("fw: —", id="diag-header-fw")
|
||||
yield Static("MCU ID: —", id="diag-header-uid")
|
||||
yield Static("M5: —", id="diag-header-m5")
|
||||
yield Static("M5 Bench: —", id="diag-header-m5")
|
||||
|
||||
# Рабочая зона: список тестов + результаты — занимает всё
|
||||
# оставшееся место (1fr), сама прокручивается при переполнении
|
||||
|
|
@ -84,30 +97,50 @@ class DiagScreen(Screen):
|
|||
# имеет classes="hidden" по умолчанию — height: auto + display:
|
||||
# none даёт нулевую высоту, не отнимая место у остального layout.
|
||||
with Vertical(id="diag-progress-row", classes="hidden"):
|
||||
yield ProgressBar(id="diag-progress-bar", show_eta=False)
|
||||
yield ProgressBar(
|
||||
id="diag-progress-bar", show_eta=False, show_percentage=False
|
||||
)
|
||||
yield Label("", id="diag-progress-label")
|
||||
|
||||
# Кнопки запуска — фиксированная высота 3 (под border Button)
|
||||
with Horizontal(id="diag-btn-row"):
|
||||
yield Button(
|
||||
"▶ Запустить выбранные",
|
||||
"▶ Запустить выбранные тесты",
|
||||
id="diag-btn-run-selected",
|
||||
variant="primary",
|
||||
disabled=True,
|
||||
)
|
||||
yield Button(
|
||||
"▶▶ Все тесты",
|
||||
"▶▶ Запустить все тесты",
|
||||
id="diag-btn-run-all",
|
||||
variant="default",
|
||||
disabled=True,
|
||||
)
|
||||
yield Button("✕ Выйти", id="diag-btn-quit", variant="default")
|
||||
yield Button(
|
||||
"✕ Выйти из приложения", id="diag-btn-quit", variant="default"
|
||||
)
|
||||
|
||||
# Панель confirm — auto-высота, видна только когда есть запрос
|
||||
yield ConfirmPanel(id="diag-confirm", classes="hidden")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._init_session()
|
||||
self._start_connection_watch(self._check_cdc_present)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._stop_connection_watch()
|
||||
|
||||
def _check_cdc_present(self) -> bool:
|
||||
# Не считаем потерей соединения во время активного прогона —
|
||||
# Orchestrator сам детектирует обрыв через таймаут чтения порта
|
||||
# (надёжнее: видит остановку потока данных, не просто список USB).
|
||||
if self._tests_running:
|
||||
return True
|
||||
return Flasher.detect_cdc()
|
||||
|
||||
@on(ConnectionLost)
|
||||
def _on_connection_lost(self) -> None:
|
||||
self.post_message(self.DiagDone(reason="Соединение с платой потеряно"))
|
||||
|
||||
# ── Инициализация сессии ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -118,7 +151,7 @@ class DiagScreen(Screen):
|
|||
uid = await self._fw.get_uid()
|
||||
except Exception as exc:
|
||||
logger.error("Session init failed: %s", exc)
|
||||
self.post_message(self.DiagDone())
|
||||
self.post_message(self.DiagDone(reason="Не удалось получить список тестов"))
|
||||
return
|
||||
|
||||
self._session.tests = tests
|
||||
|
|
@ -135,17 +168,17 @@ class DiagScreen(Screen):
|
|||
|
||||
def _update_header(self) -> None:
|
||||
self.query_one("#diag-header-fw", Static).update(
|
||||
f"firmware_test: {self._session.fw_version or '?'}"
|
||||
f"fw: {self._session.fw_version or '?'}"
|
||||
)
|
||||
uid = self._session.chip_uid or "—"
|
||||
self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}")
|
||||
|
||||
m5_widget = self.query_one("#diag-header-m5", Static)
|
||||
if self._session.m5_connected:
|
||||
m5_widget.update("M5: ✓ подключён")
|
||||
m5_widget.update("M5 Bench: ✓ подключён")
|
||||
m5_widget.remove_class("m5-absent")
|
||||
else:
|
||||
m5_widget.update("M5: ✕ не подключен")
|
||||
m5_widget.update("M5 Bench: ✕ нет связи")
|
||||
m5_widget.add_class("m5-absent")
|
||||
|
||||
# ── Кнопки ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -234,6 +267,13 @@ class DiagScreen(Screen):
|
|||
self._session.set_result(event.result)
|
||||
results.set_result(event.result)
|
||||
|
||||
elif event.type == OrchestratorEventType.TEST_PROGRESS:
|
||||
# Внутришаговый прогресс долгого теста (сейчас только usd):
|
||||
# card_detect, mount, write, read_compare — см. PROTOCOL.md.
|
||||
self._update_progress(
|
||||
done, total, f"Тест: {event.test_id} — {event.message}"
|
||||
)
|
||||
|
||||
elif event.type == OrchestratorEventType.CONFIRM_NEEDED:
|
||||
assert event.confirm is not None
|
||||
confirm.show_operator(
|
||||
|
|
@ -242,7 +282,7 @@ class DiagScreen(Screen):
|
|||
)
|
||||
|
||||
elif event.type == OrchestratorEventType.CONFIRM_RESOLVED:
|
||||
self._update_progress(done, total, f"HIL: {event.message}")
|
||||
self._update_progress(done, total, f"M5 Bench: {event.message}")
|
||||
|
||||
elif event.type == OrchestratorEventType.BUTTONS_PROMPT:
|
||||
assert event.confirm is not None
|
||||
|
|
@ -255,6 +295,13 @@ class DiagScreen(Screen):
|
|||
self._update_progress(done, total, f"⚠ {event.message}")
|
||||
|
||||
def _on_summary(self, summary: dict) -> None:
|
||||
aborted = summary.get("aborted", False)
|
||||
if aborted:
|
||||
self.query_one("#diag-progress-label", Label).update(
|
||||
"⚠ Прогон прерван: связь с платой потеряна"
|
||||
)
|
||||
return
|
||||
|
||||
overall = summary.get("overall", "fail")
|
||||
passed = summary.get("passed", 0)
|
||||
failed = summary.get("failed", 0)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ DataTable с колонками: Тест | HIL | Статус | Время | Д
|
|||
внутри групп статусов — исходный порядок реестра firmware_test.
|
||||
Строка с FAIL дополнительно подсвечивается красным фоном целиком.
|
||||
|
||||
Колонка "Детали" переносит длинный текст на несколько строк внутри ячейки
|
||||
(а не обрезает) — высота строки для FAIL вычисляется по длине detail
|
||||
относительно ширины колонки. Остальные статусы всегда однострочные.
|
||||
|
||||
Публичный API:
|
||||
ResultsPanel.populate(tests) — показать пустую таблицу (PENDING)
|
||||
ResultsPanel.set_running(test_id) — пометить тест как выполняющийся
|
||||
|
|
@ -17,6 +21,8 @@ DataTable с колонками: Тест | HIL | Статус | Время | Д
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
|
|
@ -47,6 +53,12 @@ _STATUS_DISPLAY: dict[TestStatus, tuple[str, str]] = {
|
|||
|
||||
_FAIL_BG = "dark_red"
|
||||
|
||||
# Ширина колонки "Детали" в символах — должна совпадать с шириной,
|
||||
# заданной явно в on_mount() через table.add_column("Детали", width=...).
|
||||
# Используется для расчёта высоты строки под перенос текста.
|
||||
_DETAIL_COL_WIDTH = 21
|
||||
_MAX_ROW_HEIGHT = 4 # не даём одной FAIL-строке занять весь экран
|
||||
|
||||
|
||||
class ResultsPanel(Widget):
|
||||
"""Правая колонка DiagScreen — таблица результатов тестов."""
|
||||
|
|
@ -70,7 +82,16 @@ class ResultsPanel(Widget):
|
|||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#results-table", DataTable)
|
||||
table.add_columns("Тест", "M5", "Статус", "Время", "Детали")
|
||||
# add_columns() (множественное число) не принимает width — без явной
|
||||
# ширины колонка "Детали" сжимается до длины заголовка ("Детали" = 6
|
||||
# символов) и обрезает любой более длинный текст, даже однострочный.
|
||||
# Используем add_column() по одной с явной шириной под каждую,
|
||||
# рассчитанной под ResultsPanel { width: 70 } (см. app.tcss).
|
||||
table.add_column("Тест", width=22)
|
||||
table.add_column("M5 Bench", width=8)
|
||||
table.add_column("Статус", width=8)
|
||||
table.add_column("Время", width=6)
|
||||
table.add_column("Детали", width=21)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -124,12 +145,25 @@ class ResultsPanel(Widget):
|
|||
|
||||
# ── Internal: строки таблицы ────────────────────────────────────────────
|
||||
|
||||
def _row_height(self, detail: str) -> int:
|
||||
"""
|
||||
Сколько строк нужно ячейке "Детали" под перенос текста.
|
||||
1 строка по умолчанию; растёт пропорционально длине detail,
|
||||
ограничено _MAX_ROW_HEIGHT чтобы один FAIL не съел весь экран
|
||||
(очень длинный detail в этом случае обрежется — лучше, чем
|
||||
одна строка съедает половину видимой таблицы).
|
||||
"""
|
||||
if not detail:
|
||||
return 1
|
||||
needed = math.ceil(len(detail) / _DETAIL_COL_WIDTH)
|
||||
return max(1, min(needed, _MAX_ROW_HEIGHT))
|
||||
|
||||
def _add_row(
|
||||
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
|
||||
) -> None:
|
||||
table = self.query_one("#results-table", DataTable)
|
||||
cells = self._row_cells(test, status, duration_ms, detail)
|
||||
table.add_row(*cells, key=test.id)
|
||||
table.add_row(*cells, key=test.id, height=self._row_height(detail))
|
||||
|
||||
def _update_row(
|
||||
self, test_id: str, status: TestStatus, duration_ms: int, detail: str
|
||||
|
|
@ -141,6 +175,18 @@ class ResultsPanel(Widget):
|
|||
|
||||
table = self.query_one("#results-table", DataTable)
|
||||
cells = self._row_cells(test, status, duration_ms, detail)
|
||||
new_height = self._row_height(detail)
|
||||
|
||||
# DataTable не предоставляет публичный API для изменения высоты
|
||||
# уже добавленной строки (update_cell меняет только содержимое).
|
||||
# Когда нужная высота отличается от текущей (например, тест перешёл
|
||||
# в FAIL с многострочным detail) — пересоздаём строку: remove + add.
|
||||
# Иначе — точечный update_cell, дешевле и не теряет курсор/scroll.
|
||||
current_height = table.get_row_height(test_id)
|
||||
if current_height != new_height:
|
||||
table.remove_row(test_id)
|
||||
table.add_row(*cells, key=test_id, height=new_height)
|
||||
else:
|
||||
col_keys = [c.key for c in table.ordered_columns]
|
||||
for col_key, value in zip(col_keys, cells):
|
||||
table.update_cell(test_id, col_key, value)
|
||||
|
|
@ -150,7 +196,7 @@ class ResultsPanel(Widget):
|
|||
def _row_cells(
|
||||
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
|
||||
) -> tuple:
|
||||
"""Собрать пять ячеек строки с учётом подсветки FAIL."""
|
||||
"""Собрать пять ячеек строки с учётом подсветки FAIL и переноса текста."""
|
||||
status_text, status_color = _STATUS_DISPLAY[status]
|
||||
is_fail = status == TestStatus.FAIL
|
||||
|
||||
|
|
@ -166,12 +212,17 @@ class ResultsPanel(Widget):
|
|||
time_text = f"{duration_ms / 1000:.1f}с" if duration_ms > 0 else ""
|
||||
hil_text = "[*]" if test.requires_hil else "[-]"
|
||||
|
||||
# overflow="fold" — перенос по символам на границе ячейки вместо
|
||||
# обрезания с многоточием (Textual default), detail виден целиком
|
||||
# на нескольких строках, если высота строки это позволяет.
|
||||
detail_cell = Text(detail, style=detail_style, overflow="fold")
|
||||
|
||||
return (
|
||||
Text(test.name, style=name_style),
|
||||
Text(hil_text, style=hil_style),
|
||||
Text(status_text, style=status_style),
|
||||
Text(time_text, style=time_style),
|
||||
Text(detail, style=detail_style),
|
||||
detail_cell,
|
||||
)
|
||||
|
||||
def _resort(self, table: DataTable) -> None:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class TestListPanel(Widget):
|
|||
self._hil_unavailable: dict[str, bool] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Тесты", classes="section-title")
|
||||
yield Label("Доступные тесты", classes="section-title")
|
||||
with Horizontal(id="test-list-select-row"):
|
||||
yield Button(
|
||||
"Выбрать все", id="test-list-select-all", classes="-textual-compact"
|
||||
|
|
|
|||
|
|
@ -31,16 +31,24 @@ from textual.widgets import (
|
|||
from ..flasher import Flasher
|
||||
from ..models import FlashProgress, FlashTarget
|
||||
from ..widgets import AppFrame
|
||||
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlashScreen(Screen):
|
||||
class FlashScreen(Screen, ConnectionWatcherMixin):
|
||||
"""
|
||||
Экран прошивки.
|
||||
|
||||
Messages:
|
||||
FlashDone(success, target) — прошивка завершена
|
||||
|
||||
Мониторинг соединения: пока плата не прошивается (self._flashing
|
||||
== False), каждые 1.5с проверяется наличие BootROM SDP на шине.
|
||||
Если плата физически отключена в простое — сессия считается
|
||||
недостоверной, экран сразу уходит на WaitingScreen (см. замечание
|
||||
№4 отчёта). Во время самой прошивки/erase мониторинг приостановлен —
|
||||
обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
|
|
@ -61,12 +69,12 @@ class FlashScreen(Screen):
|
|||
def compose(self) -> ComposeResult:
|
||||
with AppFrame(id="flash-frame"):
|
||||
yield Label(
|
||||
"⚡ Загрузка прошивки на плату (BootROM SDP обнаружен)",
|
||||
"⚡ Загрузка прошивки на плату индикатора (режим BootROM)",
|
||||
id="flash-title",
|
||||
)
|
||||
|
||||
with Vertical(id="flash-target-group"):
|
||||
yield Label("Выберите файл для загрузки", classes="section-title")
|
||||
yield Label("Выбор загружаемой прошивки", classes="section-title")
|
||||
with RadioSet(id="flash-radio"):
|
||||
yield RadioButton(
|
||||
"Диагностическая прошивка (firmware_test)",
|
||||
|
|
@ -78,25 +86,47 @@ class FlashScreen(Screen):
|
|||
id="radio-production",
|
||||
)
|
||||
yield RadioButton(
|
||||
"Другое (подготовленный бинарный файл)",
|
||||
"Другое",
|
||||
id="radio-custom",
|
||||
)
|
||||
with Horizontal(id="flash-custom-path", classes="hidden"):
|
||||
yield Input(
|
||||
placeholder="Путь к бинарному файлу (.bin)",
|
||||
placeholder="Имя бинарного файла в custom_binaries/ (.bin)",
|
||||
id="flash-custom-input",
|
||||
)
|
||||
|
||||
with Horizontal(id="flash-btn-row"):
|
||||
yield Button("▶ Загрузить ПО", id="flash-btn-flash", variant="warning")
|
||||
yield Button("▶ Загрузить", id="flash-btn-flash", variant="warning")
|
||||
yield Button("⚠ Очистить память", id="flash-btn-erase", variant="error")
|
||||
yield Button(
|
||||
"✕ Выйти из программы", id="flash-btn-quit", variant="default"
|
||||
"✕ Выйти из приложения", id="flash-btn-quit", variant="default"
|
||||
)
|
||||
|
||||
yield ProgressBar(id="flash-progress-bar", show_eta=False, classes="hidden")
|
||||
yield ProgressBar(
|
||||
id="flash-progress-bar",
|
||||
show_eta=False,
|
||||
show_percentage=False,
|
||||
classes="hidden",
|
||||
)
|
||||
yield Log(id="flash-log", auto_scroll=True)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._start_connection_watch(self._check_sdp_present)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._stop_connection_watch()
|
||||
|
||||
def _check_sdp_present(self) -> bool:
|
||||
# Не считаем потерей соединения, если идёт активная операция —
|
||||
# flash_usb.py сам обработает реальный обрыв через subprocess.
|
||||
if self._flashing:
|
||||
return True
|
||||
return Flasher.detect_sdp()
|
||||
|
||||
@on(ConnectionLost)
|
||||
def _on_connection_lost(self) -> None:
|
||||
self.post_message(self.FlashDone(success=False, target=None))
|
||||
|
||||
# ── Обработчики ───────────────────────────────────────────────────────────
|
||||
|
||||
@on(RadioSet.Changed, "#flash-radio")
|
||||
|
|
@ -114,7 +144,7 @@ class FlashScreen(Screen):
|
|||
return
|
||||
target, bin_path = self._resolve_target()
|
||||
if target is None:
|
||||
self._log("⚠ Укажите путь к бинарному файлу!")
|
||||
self._log("⚠ Укажите корректное имя бинарного файла")
|
||||
return
|
||||
self._do_flash(target, bin_path)
|
||||
|
||||
|
|
@ -153,15 +183,11 @@ class FlashScreen(Screen):
|
|||
async def _do_erase(self) -> None:
|
||||
self._set_busy(True)
|
||||
self._show_progress(True)
|
||||
self._log("⚠ Очистка флеш памяти (~30 с)...")
|
||||
self._log("⚠ Очистка памяти (~30 с)...")
|
||||
ok = await self._flasher.erase_chip(progress_cb=self._on_progress)
|
||||
self._set_busy(False)
|
||||
self._finish_progress(ok)
|
||||
self._log(
|
||||
"✅ Очистка флеш памяти завершена"
|
||||
if ok
|
||||
else "❌ Очистка флеш памяти: ошибка"
|
||||
)
|
||||
self._log("✅ Очистка памяти завершена" if ok else "❌ Очистка памяти: ошибка")
|
||||
|
||||
# ── Вспомогательные ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ class PostFlashScreen(Screen):
|
|||
|
||||
with Horizontal(id="post-flash-btn-row"):
|
||||
yield Button("✓ Готово", id="post-flash-btn-ok", variant="success")
|
||||
yield Button("✕ Выйти", id="post-flash-btn-quit", variant="default")
|
||||
yield Button(
|
||||
"✕ Выйти из приложения", id="post-flash-btn-quit", variant="default"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_countdown()
|
||||
|
|
|
|||
|
|
@ -3,10 +3,19 @@ waiting.py — экран ожидания подключения платы.
|
|||
|
||||
Опрашивает USB каждые _DETECT_INTERVAL_S секунд через Flasher.
|
||||
При обнаружении SDP или CDC отправляет DeviceDetected message в App.
|
||||
|
||||
Принимает опциональный disconnect_reason — короткое сообщение о причине
|
||||
возврата на этот экран (например, "Соединение с платой потеряно"),
|
||||
показывается несколько секунд поверх обычной подсказки, затем исчезает
|
||||
само. Нужно, чтобы потеря USB во время прошивки/диагностики (см. отчёт,
|
||||
замечание №4) не выглядела как необъяснимый скачок экрана — сервисник
|
||||
должен понимать, что это осознанное поведение, а не баг.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.css.query import NoMatches
|
||||
from textual.message import Message
|
||||
|
|
@ -20,6 +29,7 @@ from ..widgets import AppFrame
|
|||
|
||||
_DETECT_INTERVAL_S = 1.5
|
||||
_SPIN_INTERVAL_S = 0.1
|
||||
_REASON_DISPLAY_S = 4.0
|
||||
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
|
||||
|
|
@ -38,27 +48,46 @@ class WaitingScreen(Screen):
|
|||
super().__init__()
|
||||
self.mode = mode
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
def __init__(self, disconnect_reason: Optional[str] = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._disconnect_reason = disconnect_reason
|
||||
self._spinner_idx: int = 0
|
||||
self._detect_timer: Timer | None = None
|
||||
self._spin_timer: Timer | None = None
|
||||
self._reason_timer: Timer | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with AppFrame(id="waiting-frame"):
|
||||
yield Static("TFT Indicator Board\nService Tool", id="waiting-logo")
|
||||
yield Static("Подключите плату к USB...", id="waiting-hint")
|
||||
yield Static("", id="waiting-reason", classes="hidden")
|
||||
yield Static("Подключите плату индикатора к USB...", id="waiting-hint")
|
||||
yield Static(_SPINNER_FRAMES[0], id="waiting-spinner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._detect_timer = self.set_interval(_DETECT_INTERVAL_S, self._poll_usb)
|
||||
self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin)
|
||||
if self._disconnect_reason:
|
||||
self._show_reason(self._disconnect_reason)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._stop_timers()
|
||||
|
||||
# ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _show_reason(self, reason: str) -> None:
|
||||
try:
|
||||
self.query_one("#waiting-reason", Static).update(f"⚠ {reason}")
|
||||
self.query_one("#waiting-reason").remove_class("hidden")
|
||||
except NoMatches:
|
||||
return
|
||||
self._reason_timer = self.set_timer(_REASON_DISPLAY_S, self._hide_reason)
|
||||
|
||||
def _hide_reason(self) -> None:
|
||||
try:
|
||||
self.query_one("#waiting-reason").add_class("hidden")
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _spin(self) -> None:
|
||||
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
|
||||
try:
|
||||
|
|
@ -81,3 +110,5 @@ class WaitingScreen(Screen):
|
|||
self._detect_timer.stop()
|
||||
if self._spin_timer:
|
||||
self._spin_timer.stop()
|
||||
if self._reason_timer:
|
||||
self._reason_timer.stop()
|
||||
|
|
|
|||
488
tools/production/docs/INITIAL_PLAN.md
Normal file
488
tools/production/docs/INITIAL_PLAN.md
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
# firmware_test — План разработки
|
||||
|
||||
> Версия: 0.8 | Обновлён после завершения Этапа 6г (bsp_mqs + test_mqs, hardware-verified).
|
||||
|
||||
---
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
**Цель прошивки:** диагностика платы MIMXRT1052CVJ5B на сервисе (возврат по рекламации).
|
||||
Запускается через BootROM (USB SDP), без предварительной прошивки загрузчика.
|
||||
|
||||
**Стенд:**
|
||||
|
||||
- Хост подключается через USB CDC ACM — единственный канал firmware_test
|
||||
- HIL-тесты управляются через M5StampPLC (опционально)
|
||||
- TUI-приложение оркестрирует и firmware_test (CDC), и M5 (Serial) одновременно
|
||||
|
||||
---
|
||||
|
||||
## Текущий статус
|
||||
|
||||
| Компонент | Статус | Примечание |
|
||||
| ------------------------------ | ------ | ------------------------------------------------ |
|
||||
| `bsp_usb_cdc` | ✅ | HIL тест пройден |
|
||||
| firmware_test скелет | ✅ | `main.c` + `cli.c` |
|
||||
| Протокол v2 + test_runner | ✅ | JSON-lines event-driven |
|
||||
| `bsp_sdram` + `test_sdram` | ✅ | 4 фазы: addr/data/seq/retention |
|
||||
| `bsp_qspi_flash` + `test_qspi` | ✅ | JEDEC + erase + rw + addr range |
|
||||
| `bsp_sd` + `test_usd` | ✅ | bsp_sd + FatFS, pre_confirm, 4 шага |
|
||||
| `bsp_display` + `test_display` | ✅ | 4 цвета + ротация, hardware-verified |
|
||||
| `bsp_button` + `test_buttons` | ✅ | 2 кнопки, physical detect, hardware-verified |
|
||||
| Протокол: `list_tests` | ✅ | Этап 6а, hardware-verified |
|
||||
| Протокол: `run_selected` | ✅ | Этап 6а, hardware-verified |
|
||||
| `test_opto` | ✅ | Этап 6б, hardware-verified |
|
||||
| `test_can` | ✅ | Этап 6в, hardware-verified |
|
||||
| HIL pytest firmware_cdc | ✅ | Этап 6д, `FirmwareCdc` + `firmware_cdc` фикстура |
|
||||
| HIL pytest firmware_opto | ✅ | Этап 6е, `06_test_firmware_opto.py` |
|
||||
| HIL pytest firmware_can | ✅ | Этап 6ж, `06_test_firmware_can.py` |
|
||||
| `bsp_mqs` + `test_mqs` | ✅ | Этап 6г, hardware-verified |
|
||||
| Provisioning | ⬜ | Этап 7 |
|
||||
| TUI сервисного инженера | ⬜ | Этап 8 |
|
||||
|
||||
---
|
||||
|
||||
## Матрица тестов — итоговая
|
||||
|
||||
| ID | Название | Critical | HIL | Тип | BSP | Статус |
|
||||
| --------- | ------------------ | -------- | --- | ----------- | ------------------ | ------ |
|
||||
| `sdram` | SDRAM 32 MB | ✅ | ❌ | self | `bsp_sdram` ✅ | ✅ |
|
||||
| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | self | `bsp_qspi_flash` ✅ | ✅ |
|
||||
| `usd` | microSD (SDIO) | ❌ | ❌ | interactive | `bsp_sd` ✅ | ✅ |
|
||||
| `display` | TFT Display RGB888 | ❌ | ❌ | interactive | `bsp_display` ✅ | ✅ |
|
||||
| `buttons` | Test Buttons 1/2 | ❌ | ❌ | interactive | `bsp_button` ✅ | ✅ |
|
||||
| `mqs` | MQS Audio Out | ❌ | ❌ | interactive | `bsp_mqs` ✅ | ✅ |
|
||||
| `can` | CAN loopback | ❌ | ✅ | HIL | `bsp_can` ✅ | ✅ |
|
||||
| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | HIL | `bsp_opto` ✅ | ✅ |
|
||||
|
||||
**Убранные тесты (закрытые решения):**
|
||||
|
||||
- `uart_ttl` — LPUART1 dev-инструмент (MCU-Link VCOM), в сервисе не используется
|
||||
- `uart_iso` — RS_RX физически тот же пин что IN в `test_opto`, избыточно
|
||||
|
||||
---
|
||||
|
||||
## Закрытые архитектурные решения
|
||||
|
||||
> Не пересматривать без явного запроса.
|
||||
|
||||
### Этапы 1–5 (ранее зафиксированные)
|
||||
|
||||
- **Транспорт:** USB CDC ACM — единственный канал. UART не используется в firmware_test.
|
||||
- **Парсинг JSON:** без cJSON, строковый `strstr`. Входящее поле всегда `"type"` / `"cmd"`.
|
||||
- **SDRAM и DCD:** SEMC инициализируется DCD до `main()`. `bsp_sdram_init()` только верифицирует.
|
||||
- **QSPI-функции в ITCM:** `AT_QUICKACCESS_SECTION_CODE` + `__STARTUP_INITIALIZE_RAMFUNCTION`.
|
||||
- **W25Q256/512:** dedicated 4-byte opcodes, без Enter 4-Byte Mode (0xB7).
|
||||
- **bsp_button_init():** вызывается в `init()` тест-модуля, не в `main.c`.
|
||||
- **Тест дисплея:** 4 цвета + 2 ротации. Таймаут confirm 15 с → FAIL.
|
||||
- **Тест кнопок:** физическая детекция через `bsp_button`. Хост не отправляет JSON confirm. Таймаут 10 с → SKIP.
|
||||
|
||||
### Этап 6 (новые решения)
|
||||
|
||||
- **Разделение тестов:** `requires_hil=false` (standalone) vs `requires_hil=true` (HIL).
|
||||
TUI фильтрует HIL-тесты если M5StampPLC не подключён.
|
||||
- **`list_tests`:** таргет отдаёт реестр тестов с метаданными по запросу хоста.
|
||||
TUI строит UI динамически, не хардкодит список тестов.
|
||||
- **`run_selected`:** запуск произвольного подмножества тестов по списку ID.
|
||||
Порядок выполнения — как в реестре таргета, не как в запросе.
|
||||
Таргет принимает любой список без проверки `requires_hil` — ответственность на TUI.
|
||||
- **TUI оркестрирует M5:** firmware_test не знает про M5. При `confirm_request`
|
||||
от HIL-теста TUI командует M5, получает результат, отправляет confirm.
|
||||
- **M5 опционален:** TUI при старте пробует найти M5. Не нашёл — HIL-тесты
|
||||
недоступны (серые в UI, не входят в `run_selected`).
|
||||
- **Фильтрация HIL на стороне TUI:** таргет не фильтрует по `requires_hil`.
|
||||
- **MQS стерео:** MQS MIMXRT1052 требует стерео PCM-буфер. На плате выведен
|
||||
один канал. Буфер всегда стерео (L+R идентичны).
|
||||
- **MQS тест:** захардкоженная мелодия ~4 с (A4 + E5, по 2 с каждая),
|
||||
`confirm_request("mqs_tone")`, оператор слышит → PASS/FAIL.
|
||||
`critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`.
|
||||
- **MQS порядок init:** `bsp_mqs_amp_init()` → `bsp_delay(300)` → `bsp_mqs_init()`.
|
||||
Усилитель запускается первым, чтобы успели зарядиться конденсаторы C103/C105 LM4875M.
|
||||
Нарушение порядка приводит к щелчку при старте или отсутствию звука.
|
||||
- **MQS USB keepalive:** воспроизведение через `bsp_mqs_play()` (async, не blocking),
|
||||
параллельно крутится `bsp_usb_cdc_poll()`. Blocking-вариант голодает USB за ~4 с.
|
||||
- **`pwmchannelenable` (NXP SDK ≥ 2.13):** поле в `pwm_signal_param_t` обязательно
|
||||
выставлять в `true`. При инициализации через designated initializers без явного
|
||||
указания равно `false` → `PWM_SetupPwm()` не выставляет `OUTEN` → ШИМ не выходит
|
||||
на пин. Маскируется после отладочной сессии (отладчик оставляет `OUTEN` от прошлого
|
||||
прогона). Воспроизводится только при cold reset.
|
||||
- **ERRATA 50235 (FlexCAN + USB):** `FLEXCAN_Init()` содержит assert на
|
||||
`CCM_CCGR5_CG12` (LPUART clock gate). После `bsp_usb_cdc_init()` gate
|
||||
может быть закрыт → assert → HardFault. Workaround: `CLOCK_EnableClock(kCLOCK_Lpuart1)`
|
||||
перед `FLEXCAN_Init()` внутри `bsp_can_init()`. Gate оставляется открытым —
|
||||
закрывать не нужно, LPUART1 тактируется с минимальным потреблением.
|
||||
`bsp_can_init()` вызывается из `main()` после `bsp_usb_cdc_init()`.
|
||||
- **`firmware_cdc` фикстура:** не ждёт `session_start` (одноразовое событие при
|
||||
старте, может быть пропущено). Проверяет живость через `ping → pong`.
|
||||
- **`bsp_opto_force_read()`:** добавлен в BSP API для синхронного чтения пина
|
||||
без дебаунса. Обновляет `confirmed_state`, сбрасывает `pending`. Используется
|
||||
в `test_opto.c` после settle — обходит race condition когда чётное число ISR
|
||||
при дребезге реле оставляет `pending=false` с устаревшим `confirmed_state`.
|
||||
- **`bsp_opto_process()` в `test_opto.c`:** вызывается в settle loop после confirm,
|
||||
но не в `test_runner_wait_confirm()`. Финальное чтение — через `bsp_opto_force_read()`.
|
||||
- **Оркестратор оpto:** `RELAY_ON_S=0.15`, `RELAY_OFF_S=0.5` в `06_test_firmware_opto.py`.
|
||||
Фиксированный sleep достаточен — реле переключается до отправки `confirmed:true`,
|
||||
`bsp_opto_force_read()` читает финальное состояние пина напрямую.
|
||||
|
||||
### Этап 8 (TUI решения)
|
||||
|
||||
- **Прошивка — только USB SDP:** SWD недоступен сервиснику. spsdk (sdphost + blhost).
|
||||
Оператор сам переставляет перемычку BOOT — это ок, документируется.
|
||||
- **TUI автодетект:** определяет подключение по VID/PID — SDP BootROM (1FC9:0130)
|
||||
или CDC firmware_test (session_start) — и показывает соответствующий экран.
|
||||
- **Фреймворк TUI:** Textual (Python). Нативный async, реальные виджеты,
|
||||
работает в SSH-сессии, вписывается в uv-экосистему.
|
||||
- **tools/shared/m5_agent.py:** общая M5-логика, импортируется из `tools/hil/`
|
||||
и `tools/production/`.
|
||||
|
||||
---
|
||||
|
||||
## Этап 6 — test_can + test_opto + test_mqs + протокол ✅ ЗАВЕРШЁН
|
||||
|
||||
### 6а — Расширение протокола ✅
|
||||
|
||||
**Файлы:** `protocol.h`, `protocol.c`, `cli.c`, `test_runner.c`, `PROTOCOL.md`
|
||||
|
||||
#### Новая команда `list_tests`
|
||||
|
||||
```json
|
||||
→ {"type":"cmd","cmd":"list_tests"}
|
||||
← {"type":"test_list","tests":[
|
||||
{"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false},
|
||||
{"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false},
|
||||
{"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false},
|
||||
{"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false},
|
||||
{"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false},
|
||||
{"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false},
|
||||
{"id":"can","name":"CAN loopback","critical":false,"requires_hil":true},
|
||||
{"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true}
|
||||
]}
|
||||
```
|
||||
|
||||
#### Новая команда `run_selected`
|
||||
|
||||
```json
|
||||
→ {"type":"cmd","cmd":"run_selected","tests":["sdram","qspi","display"]}
|
||||
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
|
||||
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
|
||||
← {"type":"test_begin","id":"qspi",...}
|
||||
← {"type":"test_result","id":"qspi",...}
|
||||
← {"type":"test_begin","id":"display",...}
|
||||
← {"type":"test_result","id":"display",...}
|
||||
← {"type":"summary","passed":3,"failed":0,"skipped":0,"overall":"pass"}
|
||||
```
|
||||
|
||||
Если хотя бы один ID не найден в реестре:
|
||||
|
||||
```json
|
||||
← {"ok":false,"error":"UNKNOWN_TEST"}
|
||||
```
|
||||
|
||||
**Реализация в `test_runner.c`:**
|
||||
|
||||
- Новый режим `RUNNER_MODE_SELECTED`
|
||||
- Статический bool-массив `g_s_selected[REGISTRY_SIZE]` — без malloc
|
||||
- `test_runner_run_selected(const char **pp_ids, size_t count)` — новая публичная функция
|
||||
|
||||
### 6б — test_opto.c ✅
|
||||
|
||||
**Файл:** `firmware/test/src/tests/test_opto.c`
|
||||
|
||||
6 шагов, попарно ACTIVE/INACTIVE для трёх каналов:
|
||||
|
||||
| Шаг | confirm_request id | M5 действие | Проверка |
|
||||
| --- | ------------------- | ----------- | -------------------------------- |
|
||||
| 1 | `opto_in1_active` | RLY3 ON | `bsp_opto_read(IN1) == ACTIVE` |
|
||||
| 2 | `opto_in1_inactive` | RLY3 OFF | `bsp_opto_read(IN1) == INACTIVE` |
|
||||
| 3 | `opto_in2_active` | RLY4 ON | `bsp_opto_read(IN2) == ACTIVE` |
|
||||
| 4 | `opto_in2_inactive` | RLY4 OFF | `bsp_opto_read(IN2) == INACTIVE` |
|
||||
| 5 | `opto_rs_active` | RLY2 ON | `bsp_opto_read(RS) == ACTIVE` |
|
||||
| 6 | `opto_rs_inactive` | RLY2 OFF | `bsp_opto_read(RS) == INACTIVE` |
|
||||
|
||||
- Init: `bsp_opto_init()` единым вызовом для всех каналов
|
||||
- Верификация синхронная после confirm (M5 переключил реле до отправки `confirmed:true`)
|
||||
- FAIL при несоответствии: `detail = "<id> state mismatch: expected ACTIVE got INACTIVE"`
|
||||
- Таймаут: `PROTOCOL_CONFIRM_TIMEOUT_MS` (30 с) на каждый шаг
|
||||
|
||||
### 6в — test_can.c ✅
|
||||
|
||||
**Файл:** `firmware/test/src/tests/test_can.c`
|
||||
|
||||
2 шага, оба направления независимо:
|
||||
|
||||
**Шаг 1 — RX (M5 → таргет):**
|
||||
|
||||
```bash
|
||||
confirm_request("can_rx_ready")
|
||||
→ TUI: M5.can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF])
|
||||
→ TUI: confirm(true)
|
||||
→ таргет: bsp_can_receive(&frame, 500 мс)
|
||||
→ верификация: frame.id==0x100, frame.data==[0xDE,0xAD,0xBE,0xEF]
|
||||
→ FAIL если timeout или несовпадение
|
||||
```
|
||||
|
||||
**Шаг 2 — TX (таргет → M5):**
|
||||
|
||||
```bash
|
||||
bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс)
|
||||
confirm_request("can_tx_verify")
|
||||
→ TUI: M5.can_recv(timeout=500 мс) → верификация id+data
|
||||
→ TUI: confirm(true) если M5 принял корректно, confirm(false) если нет
|
||||
→ FAIL если confirmed=false или timeout
|
||||
```
|
||||
|
||||
- `disableSelfReception=true` — таргет не слышит свой TX, только M5 верифицирует
|
||||
- Init: `bsp_can_init(&cfg)` + `bsp_can_accept_all()`
|
||||
|
||||
### 6г — bsp_mqs + test_mqs.c ✅
|
||||
|
||||
**Файлы:** `bsp/mqs/` + `firmware/test/src/tests/test_mqs.c`
|
||||
|
||||
**bsp_mqs:**
|
||||
|
||||
- SAI3 + eDMA (DMA0 канал 0) + MQS периферия
|
||||
- Стерео PCM16 буфер (L+R идентичны), один физический выход `MQS_RIGHT`
|
||||
- Усилитель LM4875M управляется PWM4 SM0 через RC-фильтр и буферный ОУ LM358
|
||||
- API: `bsp_mqs_init/deinit`, `bsp_mqs_play/play_blocking`, `bsp_mqs_stop`,
|
||||
`bsp_mqs_is_busy`, `bsp_mqs_amp_init/deinit`, `bsp_mqs_amp_set_volume`
|
||||
|
||||
**test_mqs:**
|
||||
|
||||
- Мелодия ~4 с: A4 (440 Гц) + E5 (659 Гц), по 2 с каждая, целочисленная LUT-синусоида
|
||||
- Воспроизведение через `bsp_mqs_play()` (async) с `bsp_usb_cdc_poll()` в цикле
|
||||
- `confirm_request("mqs_tone", "Do you hear a tone?", 15000)` → PASS/FAIL
|
||||
- Порядок init: amp → delay 300 мс → mqs → build_melody (однократно, флаг)
|
||||
- `critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL`
|
||||
|
||||
### 6д — HIL pytest для firmware_test ✅
|
||||
|
||||
**Файлы:**
|
||||
|
||||
```
|
||||
tools/hil/conftest.py ← фикстура firmware_cdc
|
||||
tools/hil/06_test_firmware_opto.py
|
||||
tools/hil/06_test_firmware_can.py
|
||||
```
|
||||
|
||||
**Фикстура `firmware_cdc`:**
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="module")
|
||||
def firmware_cdc(m5):
|
||||
"""
|
||||
Открывает USB CDC порт firmware_test.
|
||||
firmware_test уже прошит в Flash (не загружается pyOCD).
|
||||
Проверяет живость через ping → pong.
|
||||
"""
|
||||
```
|
||||
|
||||
**`FirmwareCdcClient`** — тонкий клиент:
|
||||
|
||||
- `send_cmd(cmd_dict)` — отправить JSON команду
|
||||
- `wait_event(type, timeout_s)` — ждать события нужного типа
|
||||
- `confirm(id, ok)` — отправить `{"type":"confirm","id":"...","confirmed":true/false}`
|
||||
- `run_test(id)` — запустить тест, вернуть test_result dict
|
||||
|
||||
**Justfile:**
|
||||
|
||||
```bash
|
||||
hil-firmware-opto → pytest 06_test_firmware_opto.py -v
|
||||
hil-firmware-can → pytest 06_test_firmware_can.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этап 7 — Provisioning
|
||||
|
||||
### Что нужно
|
||||
|
||||
1. Читать `OCOTP_UNIQUE_ID` через SDK `fsl_ocotp`
|
||||
2. Отправить `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary`
|
||||
3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста
|
||||
4. Записывать статус в Flash (первый сектор после прошивки, вне XIP)
|
||||
|
||||
### BSP (предварительно)
|
||||
|
||||
```c
|
||||
/* bsp/provisioning/include/bsp/provisioning.h */
|
||||
bsp_status_t bsp_prov_read_uid(uint8_t *p_uid, size_t len); /* 8 байт из OCOTP */
|
||||
```
|
||||
|
||||
### Открытые вопросы — Этап 7
|
||||
|
||||
- [ ] Что именно записывать как «пройдено»: флаг в Flash или только отправить UID?
|
||||
- [ ] Нужна ли защита от повторного provisioning (write-once)?
|
||||
|
||||
---
|
||||
|
||||
## Этап 8 — TUI сервисного инженера
|
||||
|
||||
### Стек технологий
|
||||
|
||||
| Компонент | Выбор | Обоснование |
|
||||
| ------------- | ------------- | ----------------------------------------------------- |
|
||||
| TUI фреймворк | **Textual** | Нативный async, виджеты, SSH-совместим, uv-экосистема |
|
||||
| Serial | pyserial | Уже в стеке (tools/hil) |
|
||||
| Прошивка | spsdk | sdphost + blhost, уже в tools/host |
|
||||
| Конфигурация | python-dotenv | .env файл, совместим с существующим подходом |
|
||||
|
||||
### Структура приложения
|
||||
|
||||
```bash
|
||||
tools/production/
|
||||
├── pyproject.toml ← зависимости: textual, pyserial, spsdk, python-dotenv
|
||||
├── uv.lock
|
||||
├── main.py ← точка входа
|
||||
├── app/
|
||||
│ ├── tui.py ← Textual App, экраны, layout
|
||||
│ ├── firmware_client.py ← USB CDC asyncio клиент firmware_test
|
||||
│ ├── m5_client.py ← M5 Serial клиент (импортирует tools/shared/m5_agent.py)
|
||||
│ ├── flasher.py ← USB SDP обёртка над spsdk
|
||||
│ ├── orchestrator.py ← confirm_request → M5 action → confirm response
|
||||
│ └── models.py ← TestInfo, TestResult, SessionState (dataclasses)
|
||||
└── README.md
|
||||
|
||||
tools/shared/
|
||||
└── m5_agent.py ← общая M5-логика для hil/ и production/
|
||||
```
|
||||
|
||||
### Два режима работы
|
||||
|
||||
**Режим A — Прошивка** (триггер: VID/PID 1FC9:0130 обнаружен — BootROM SDP)
|
||||
|
||||
```
|
||||
┌─ Прошивка платы ─────────────────────────────────┐
|
||||
│ Обнаружен BootROM (SDP режим) │
|
||||
│ │
|
||||
│ Что прошить? │
|
||||
│ ◉ firmware_test (диагностика) │
|
||||
│ ○ Production (bootloader + tft_app) │
|
||||
│ │
|
||||
│ Файл: [/path/to/firmware_test_hab.bin ···] │
|
||||
│ │
|
||||
│ [ Прошить ] │
|
||||
│ │
|
||||
│ ████████████░░░░░░ 64% Запись во Flash... │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Режим B — Диагностика** (триггер: session_start получен по CDC)
|
||||
|
||||
```
|
||||
┌─ Диагностика платы fw:0.1.0 ─────────────────────┐
|
||||
│ M5StampPLC: ✓ подключён │ Плата: IMXRT1052 │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ Выбор тестов: │ Результаты: │
|
||||
│ ☑ SDRAM 32 MB │ sdram ✓ PASS │
|
||||
│ ☑ QSPI Flash │ qspi ✓ PASS │
|
||||
│ ☑ microSD │ usd ✗ FAIL │
|
||||
│ ☑ TFT Display │ mount failed: 5 │
|
||||
│ ☑ Кнопки │ display ✓ PASS │
|
||||
│ ☑ MQS Audio │ buttons ✓ PASS │
|
||||
│ ☑ CAN loopback [HIL] │ mqs ✓ PASS │
|
||||
│ ☑ Оптовходы [HIL] │ ... │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ [ Запустить выбранные ] [ Все тесты ] │
|
||||
│ ████████████████░░░░ 80% Тест: display │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ ⚠ Экран залит красным цветом? │
|
||||
│ [ ✓ Да ] [ ✗ Нет ] │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Поведение confirm_request в TUI
|
||||
|
||||
| Тип теста | Источник confirm | Действие TUI |
|
||||
| -------------------- | ------------------ | --------------------------------------------- |
|
||||
| standalone (display) | оператор | показать prompt, кнопки OK/FAIL, countdown |
|
||||
| standalone (mqs) | оператор | показать prompt, кнопки OK/FAIL, countdown |
|
||||
| standalone (buttons) | физическое нажатие | показать инструкцию, ждать test_result |
|
||||
| HIL (opto, can) | оркестратор | auto: M5 action → confirm (оператор не видит) |
|
||||
|
||||
HIL confirm полностью автоматический — оператор видит только прогресс, не интерактивный prompt.
|
||||
|
||||
### Конфигурация (.env)
|
||||
|
||||
```ini
|
||||
# Существующие переменные (tools/hil/.env):
|
||||
HIL_VCOM_PORT=/dev/ttyACM0
|
||||
HIL_M5_PORT=/dev/ttyACM1
|
||||
|
||||
# Новые переменные для production TUI:
|
||||
SERVICE_CDC_PORT=AUTO # AUTO = автодетект по session_start
|
||||
SERVICE_M5_PORT=AUTO # AUTO = автодетект, пусто = без M5
|
||||
FIRMWARE_TEST_BIN=build/Release/firmware_test_hab.bin
|
||||
PRODUCTION_BIN_BOOT=build/Release/bootloader_hab.bin
|
||||
PRODUCTION_BIN_APP=build/Release/tft_app_hab.bin
|
||||
```
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
just host::service-tui # запустить TUI сервисного инженера
|
||||
just host::service-flash <bin> # прошить без TUI (для автоматизации)
|
||||
```
|
||||
|
||||
### Процесс работы сервисника
|
||||
|
||||
**Диагностика (firmware_test уже в Flash):**
|
||||
|
||||
```bash
|
||||
1. Плата в нормальном режиме (BOOT_MOD_1 → GND)
|
||||
2. Подключить USB к сервисному ПК
|
||||
3. just host::service-tui → TUI обнаружил session_start → Режим B
|
||||
4. Выбрать тесты → Запустить → Смотреть результаты
|
||||
```
|
||||
|
||||
**Перепрошивка (нужна новая версия firmware_test или production):**
|
||||
|
||||
```bash
|
||||
1. Перемычка BOOT_MOD_1 → 3V3
|
||||
2. Reset, подключить USB
|
||||
3. TUI обнаружил 1FC9:0130 → Режим A
|
||||
4. Выбрать бинарь → Прошить
|
||||
5. Перемычка BOOT_MOD_1 → GND → Reset → TUI переходит в Режим B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
```
|
||||
✅ Этап 1 протокол v2 + runner
|
||||
✅ Этап 2 bsp_sdram + test_sdram
|
||||
✅ Этап 3 bsp_qspi_flash + test_qspi
|
||||
✅ Этап 4 bsp_sd + test_usd
|
||||
✅ Этап 5 display + buttons
|
||||
✅ Этап 6а протокол: list_tests + run_selected
|
||||
✅ Этап 6б test_opto.c + hardware верификация
|
||||
✅ Этап 6в test_can.c + hardware верификация
|
||||
✅ Этап 6д HIL pytest: firmware_cdc фикстура (FirmwareCdc + firmware_cdc)
|
||||
✅ Этап 6е HIL pytest: 06_test_firmware_opto.py
|
||||
✅ Этап 6ж HIL pytest: 06_test_firmware_can.py
|
||||
✅ Этап 6г bsp_mqs + test_mqs.c + hardware верификация
|
||||
|
||||
⬜ Этап 7 Provisioning (OCOTP UID + Flash-флаг) ← СЛЕДУЮЩИЙ ШАГ
|
||||
|
||||
⬜ Этап 8а tools/production/ скелет + models + clients
|
||||
⬜ Этап 8б orchestrator + базовый Textual UI (список тестов, запуск, результаты)
|
||||
⬜ Этап 8в Экран прошивки (flasher + SDP автодетект)
|
||||
⬜ Этап 8г Provisioning в TUI
|
||||
⬜ Этап 8д tools/shared/m5_agent.py (рефакторинг общей M5-логики)
|
||||
|
||||
⬜ Этап 9 Параллельно: обновить README + DEV_ARCH.md под финальную архитектуру
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Зависимости между этапами
|
||||
|
||||
```
|
||||
✅ 6а (протокол) → ✅ 6б (opto) → ✅ 6в (can) → ✅ 6г (mqs)
|
||||
↓
|
||||
✅ 6д (conftest) → ✅ 6е (opto pytest) → ✅ 6ж (can pytest)
|
||||
↓
|
||||
⬜ 7 (provisioning)
|
||||
↓
|
||||
⬜ 8 (TUI)
|
||||
```
|
||||
7
tools/production/docs/TUI_PLAN.md
Normal file
7
tools/production/docs/TUI_PLAN.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# TUI Service Tool Plan
|
||||
|
||||
1. Навести порядок с экранами "USB-POLL", "TRANSFER", возможно сделать bootlogo с версией программы и лого компании
|
||||
2. Уменьшить размер активной области до предела и сделать его равным для всех экранов
|
||||
3. Добавить обратный отчет в тесте buttons (таймаут)
|
||||
4. Выгрузка результатов тестов в json файл с привязкой к UID микроконтроллера
|
||||
5. Возможно ли копировать UID с экрана терминала?
|
||||
8
tools/production/docs/TUI_REPORT_2.md
Normal file
8
tools/production/docs/TUI_REPORT_2.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# 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. В таблице тестов в столбце Детали при неудачном выполнение теста обрезается часть информации
|
||||
121
tools/production/docs/TUI_SESSION.md
Normal file
121
tools/production/docs/TUI_SESSION.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Отчёт: разработка и стабилизация 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`).
|
||||
BIN
tools/production/docs/pics/FLASH.png
Normal file
BIN
tools/production/docs/pics/FLASH.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
tools/production/docs/pics/POST_FLASH.png
Normal file
BIN
tools/production/docs/pics/POST_FLASH.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
tools/production/docs/pics/TEST_SCREEN.png
Normal file
BIN
tools/production/docs/pics/TEST_SCREEN.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
tools/production/docs/pics/WAITING.png
Normal file
BIN
tools/production/docs/pics/WAITING.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
tools/production/docs/pics/WAITING_AFTER_LOSS.png
Normal file
BIN
tools/production/docs/pics/WAITING_AFTER_LOSS.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue