# firmware_test: TUI app current state

This commit is contained in:
Dmitry Akimov 2026-06-30 17:31:05 +03:00
parent 0b16f5cf19
commit e2f00eca99
23 changed files with 1005 additions and 1912 deletions

1
.gitignore vendored
View file

@ -59,6 +59,7 @@ build*
Testing/ Testing/
!docs/testing !docs/testing
!build.just !build.just
service_tui.log
# Файлы настройки среды разработки # Файлы настройки среды разработки
.project .project

View file

@ -59,8 +59,16 @@ class ServiceApp(App):
""" """
После прошивки: После прошивки:
- firmware_test + успех PostFlashScreen (промпт смены BootMode) - 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: if event.success and event.target == FlashTarget.FIRMWARE_TEST:
self.switch_screen(PostFlashScreen()) self.switch_screen(PostFlashScreen())
else: else:
@ -72,10 +80,10 @@ class ServiceApp(App):
self.switch_screen(WaitingScreen()) self.switch_screen(WaitingScreen())
@on(DiagScreen.DiagDone) @on(DiagScreen.DiagDone)
def _on_diag_done(self) -> None: def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
"""После диагностики — отключиться, вернуться в Waiting.""" """После диагностики — отключиться, вернуться в Waiting."""
self._disconnect() self._disconnect()
self.switch_screen(WaitingScreen()) self.switch_screen(WaitingScreen(disconnect_reason=event.reason))
# ── Подключение к firmware_test ─────────────────────────────────────────── # ── Подключение к firmware_test ───────────────────────────────────────────

View file

@ -38,6 +38,19 @@ AppFrame {
text-style: bold; 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 { #waiting-hint {
margin-top: 2; margin-top: 2;
color: $text-muted; color: $text-muted;

View file

@ -159,6 +159,12 @@ class FirmwareClient:
""" """
Читать события до получения одного из stop_types или таймаута. Читать события до получения одного из stop_types или таймаута.
Генератор yield каждого полученного события. Генератор yield каждого полученного события.
При таймауте yield-ит синтетическое событие
{"type": "_timeout", "timeout_s": ...} перед завершением вызывающий
код (Orchestrator) должен явно обработать этот тип и не путать его
с обрывом связи без объяснения причины. Префикс "_" отличает это
от реальных событий протокола firmware_test.
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s deadline = loop.time() + timeout_s
@ -171,6 +177,7 @@ class FirmwareClient:
if event.get("type") in stop_types: if event.get("type") in stop_types:
return return
logger.warning("_recv_until timeout after %.1f s", timeout_s) logger.warning("_recv_until timeout after %.1f s", timeout_s)
yield {"type": "_timeout", "timeout_s": timeout_s}
# ── Public API ────────────────────────────────────────────────────────── # ── Public API ──────────────────────────────────────────────────────────

View file

@ -28,6 +28,11 @@ orchestrator.py — оркестратор confirm_request для TUI.
"btn*" buttons (нет confirm, ждём test_result) "btn*" buttons (нет confirm, ждём test_result)
всё остальное operator (показать prompt) всё остальное operator (показать prompt)
Помимо confirm_request, протокол v2 определяет "progress" внутришаговые
информационные события долгих тестов (сейчас только usd: card_detect,
mount, write, read_compare см. docs/testing/PROTOCOL.md). Эти события
не требуют ответа, только отображаются как текущая фаза прогресса.
Публичный API: Публичный API:
Orchestrator.run_tests(test_ids) запустить тесты, yield OrchestratorEvent Orchestrator.run_tests(test_ids) запустить тесты, yield OrchestratorEvent
""" """
@ -67,6 +72,9 @@ _CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF]
_CAN_TX_ID = 0x200 _CAN_TX_ID = 0x200
_CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE] _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): class OrchestratorEventType(Enum):
TEST_BEGIN = auto() # тест начался TEST_BEGIN = auto() # тест начался
TEST_RESULT = auto() # тест завершился TEST_RESULT = auto() # тест завершился
TEST_PROGRESS = auto() # внутришаговый прогресс долгого теста (usd и т.п.)
CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone) CONFIRM_NEEDED = auto() # нужен ответ оператора (standalone)
CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически CONFIRM_RESOLVED = auto() # HIL confirm выполнен автоматически
BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm) BUTTONS_PROMPT = auto() # показать инструкцию для buttons (без confirm)
SUMMARY = auto() # итог всей сессии SUMMARY = auto() # итог всей сессии
ERROR = auto() # ошибка протокола или M5 ERROR = auto() # ошибка протокола, M5, или обрыв по таймауту
@dataclass @dataclass
@ -137,14 +146,24 @@ class Orchestrator:
Yields OrchestratorEvent в порядке поступления событий от firmware_test. Yields OrchestratorEvent в порядке поступления событий от firmware_test.
Блокируется на CONFIRM_NEEDED до вызова resolve_operator_confirm(). Блокируется на 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): async for raw in self._fw.run_selected(test_ids):
event_type = raw.get("type", "") event_type = raw.get("type", "")
if event_type == "test_begin": if event_type == "test_begin":
current_test_id = raw.get("id", "")
yield OrchestratorEvent( yield OrchestratorEvent(
type=OrchestratorEventType.TEST_BEGIN, type=OrchestratorEventType.TEST_BEGIN,
test_id=raw.get("id", ""), test_id=current_test_id,
test_name=raw.get("name", ""), test_name=raw.get("name", ""),
) )
@ -161,12 +180,24 @@ class Orchestrator:
duration_ms=raw.get("ms", 0), duration_ms=raw.get("ms", 0),
detail=raw.get("detail", ""), detail=raw.get("detail", ""),
) )
current_test_id = ""
yield OrchestratorEvent( yield OrchestratorEvent(
type=OrchestratorEventType.TEST_RESULT, type=OrchestratorEventType.TEST_RESULT,
test_id=result.id, test_id=result.id,
result=result, 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": elif event_type == "confirm_request":
confirm = ConfirmRequest( confirm = ConfirmRequest(
id=raw.get("id", ""), id=raw.get("id", ""),
@ -177,16 +208,57 @@ class Orchestrator:
yield ev yield ev
elif event_type == "summary": elif event_type == "summary":
got_summary = True
yield OrchestratorEvent( yield OrchestratorEvent(
type=OrchestratorEventType.SUMMARY, type=OrchestratorEventType.SUMMARY,
summary=raw, 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( yield OrchestratorEvent(
type=OrchestratorEventType.ERROR, 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 ──────────────────────────────────────────── # ── Маршрутизация confirm ────────────────────────────────────────────

View 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]

View file

@ -7,6 +7,13 @@ DiagScreen координирует три виджета:
ConfirmPanel confirm_request оператора (нижняя панель) ConfirmPanel confirm_request оператора (нижняя панель)
и Orchestrator маршрутизатор confirm_request. и Orchestrator маршрутизатор confirm_request.
Мониторинг соединения (см. отчёт, замечание 4): пока тесты не запущены,
каждые 1.5с проверяется наличие CDC-порта на шине. Во время активного
прогона мониторинг приостановлен таймаут чтения порта внутри Orchestrator
уже детектирует обрыв связи надёжнее (видит реальную остановку потока
данных, а не просто исчезновение устройства из списка портов) и сам
формирует понятный результат для прерванного теста.
""" """
from __future__ import annotations from __future__ import annotations
@ -24,10 +31,12 @@ from textual.screen import Screen
from textual.widgets import Button, Label, ProgressBar, Static from textual.widgets import Button, Label, ProgressBar, Static
from ...firmware_client import FirmwareClient from ...firmware_client import FirmwareClient
from ...flasher import Flasher
from ...m5_client import M5Client from ...m5_client import M5Client
from ...models import SessionState from ...models import SessionState
from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType from ...orchestrator import Orchestrator, OrchestratorEvent, OrchestratorEventType
from ...widgets import AppFrame from ...widgets import AppFrame
from ..connection_watcher import ConnectionLost, ConnectionWatcherMixin
from .confirm_panel import ConfirmPanel from .confirm_panel import ConfirmPanel
from .results import ResultsPanel from .results import ResultsPanel
from .test_list import TestListPanel from .test_list import TestListPanel
@ -35,7 +44,7 @@ from .test_list import TestListPanel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DiagScreen(Screen): class DiagScreen(Screen, ConnectionWatcherMixin):
""" """
Экран диагностики. Экран диагностики.
@ -52,6 +61,10 @@ class DiagScreen(Screen):
class DiagDone(Message): class DiagDone(Message):
"""Сессия диагностики завершена.""" """Сессия диагностики завершена."""
def __init__(self, reason: Optional[str] = None) -> None:
super().__init__()
self.reason = reason
def __init__( def __init__(
self, self,
firmware: FirmwareClient, firmware: FirmwareClient,
@ -70,9 +83,9 @@ class DiagScreen(Screen):
with AppFrame(id="diag-frame"): with AppFrame(id="diag-frame"):
# Шапка — фиксированная высота 3 # Шапка — фиксированная высота 3
with Horizontal(id="diag-header"): 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("MCU ID: —", id="diag-header-uid")
yield Static("M5: —", id="diag-header-m5") yield Static("M5 Bench: —", id="diag-header-m5")
# Рабочая зона: список тестов + результаты — занимает всё # Рабочая зона: список тестов + результаты — занимает всё
# оставшееся место (1fr), сама прокручивается при переполнении # оставшееся место (1fr), сама прокручивается при переполнении
@ -84,30 +97,50 @@ class DiagScreen(Screen):
# имеет classes="hidden" по умолчанию — height: auto + display: # имеет classes="hidden" по умолчанию — height: auto + display:
# none даёт нулевую высоту, не отнимая место у остального layout. # none даёт нулевую высоту, не отнимая место у остального layout.
with Vertical(id="diag-progress-row", classes="hidden"): 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") yield Label("", id="diag-progress-label")
# Кнопки запуска — фиксированная высота 3 (под border Button) # Кнопки запуска — фиксированная высота 3 (под border Button)
with Horizontal(id="diag-btn-row"): with Horizontal(id="diag-btn-row"):
yield Button( yield Button(
"▶ Запустить выбранные", "▶ Запустить выбранные тесты",
id="diag-btn-run-selected", id="diag-btn-run-selected",
variant="primary", variant="primary",
disabled=True, disabled=True,
) )
yield Button( yield Button(
"▶▶ Все тесты", "▶▶ Запустить все тесты",
id="diag-btn-run-all", id="diag-btn-run-all",
variant="default", variant="default",
disabled=True, disabled=True,
) )
yield Button("✕ Выйти", id="diag-btn-quit", variant="default") yield Button(
"✕ Выйти из приложения", id="diag-btn-quit", variant="default"
)
# Панель confirm — auto-высота, видна только когда есть запрос # Панель confirm — auto-высота, видна только когда есть запрос
yield ConfirmPanel(id="diag-confirm", classes="hidden") yield ConfirmPanel(id="diag-confirm", classes="hidden")
def on_mount(self) -> None: def on_mount(self) -> None:
self._init_session() 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() uid = await self._fw.get_uid()
except Exception as exc: except Exception as exc:
logger.error("Session init failed: %s", exc) logger.error("Session init failed: %s", exc)
self.post_message(self.DiagDone()) self.post_message(self.DiagDone(reason="Не удалось получить список тестов"))
return return
self._session.tests = tests self._session.tests = tests
@ -135,17 +168,17 @@ class DiagScreen(Screen):
def _update_header(self) -> None: def _update_header(self) -> None:
self.query_one("#diag-header-fw", Static).update( 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 "" uid = self._session.chip_uid or ""
self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}") self.query_one("#diag-header-uid", Static).update(f"MCU ID: {uid}")
m5_widget = self.query_one("#diag-header-m5", Static) m5_widget = self.query_one("#diag-header-m5", Static)
if self._session.m5_connected: if self._session.m5_connected:
m5_widget.update("M5: ✓ подключён") m5_widget.update("M5 Bench: ✓ подключён")
m5_widget.remove_class("m5-absent") m5_widget.remove_class("m5-absent")
else: else:
m5_widget.update("M5: ✕ не подключен") m5_widget.update("M5 Bench: ✕ нет связи")
m5_widget.add_class("m5-absent") m5_widget.add_class("m5-absent")
# ── Кнопки ─────────────────────────────────────────────────────────────── # ── Кнопки ───────────────────────────────────────────────────────────────
@ -234,6 +267,13 @@ class DiagScreen(Screen):
self._session.set_result(event.result) self._session.set_result(event.result)
results.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: elif event.type == OrchestratorEventType.CONFIRM_NEEDED:
assert event.confirm is not None assert event.confirm is not None
confirm.show_operator( confirm.show_operator(
@ -242,7 +282,7 @@ class DiagScreen(Screen):
) )
elif event.type == OrchestratorEventType.CONFIRM_RESOLVED: 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: elif event.type == OrchestratorEventType.BUTTONS_PROMPT:
assert event.confirm is not None assert event.confirm is not None
@ -255,6 +295,13 @@ class DiagScreen(Screen):
self._update_progress(done, total, f"{event.message}") self._update_progress(done, total, f"{event.message}")
def _on_summary(self, summary: dict) -> None: 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") overall = summary.get("overall", "fail")
passed = summary.get("passed", 0) passed = summary.get("passed", 0)
failed = summary.get("failed", 0) failed = summary.get("failed", 0)

View file

@ -8,6 +8,10 @@ DataTable с колонками: Тест | HIL | Статус | Время | Д
внутри групп статусов исходный порядок реестра firmware_test. внутри групп статусов исходный порядок реестра firmware_test.
Строка с FAIL дополнительно подсвечивается красным фоном целиком. Строка с FAIL дополнительно подсвечивается красным фоном целиком.
Колонка "Детали" переносит длинный текст на несколько строк внутри ячейки
(а не обрезает) высота строки для FAIL вычисляется по длине detail
относительно ширины колонки. Остальные статусы всегда однострочные.
Публичный API: Публичный API:
ResultsPanel.populate(tests) показать пустую таблицу (PENDING) ResultsPanel.populate(tests) показать пустую таблицу (PENDING)
ResultsPanel.set_running(test_id) пометить тест как выполняющийся ResultsPanel.set_running(test_id) пометить тест как выполняющийся
@ -17,6 +21,8 @@ DataTable с колонками: Тест | HIL | Статус | Время | Д
from __future__ import annotations from __future__ import annotations
import math
from rich.style import Style from rich.style import Style
from rich.text import Text from rich.text import Text
from textual.app import ComposeResult from textual.app import ComposeResult
@ -47,6 +53,12 @@ _STATUS_DISPLAY: dict[TestStatus, tuple[str, str]] = {
_FAIL_BG = "dark_red" _FAIL_BG = "dark_red"
# Ширина колонки "Детали" в символах — должна совпадать с шириной,
# заданной явно в on_mount() через table.add_column("Детали", width=...).
# Используется для расчёта высоты строки под перенос текста.
_DETAIL_COL_WIDTH = 21
_MAX_ROW_HEIGHT = 4 # не даём одной FAIL-строке занять весь экран
class ResultsPanel(Widget): class ResultsPanel(Widget):
"""Правая колонка DiagScreen — таблица результатов тестов.""" """Правая колонка DiagScreen — таблица результатов тестов."""
@ -70,7 +82,16 @@ class ResultsPanel(Widget):
def on_mount(self) -> None: def on_mount(self) -> None:
table = self.query_one("#results-table", DataTable) 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 ──────────────────────────────────────────────────────────── # ── Public API ────────────────────────────────────────────────────────────
@ -124,12 +145,25 @@ class ResultsPanel(Widget):
# ── Internal: строки таблицы ──────────────────────────────────────────── # ── 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( def _add_row(
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
) -> None: ) -> None:
table = self.query_one("#results-table", DataTable) table = self.query_one("#results-table", DataTable)
cells = self._row_cells(test, status, duration_ms, detail) 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( def _update_row(
self, test_id: str, status: TestStatus, duration_ms: int, detail: str self, test_id: str, status: TestStatus, duration_ms: int, detail: str
@ -141,16 +175,28 @@ class ResultsPanel(Widget):
table = self.query_one("#results-table", DataTable) table = self.query_one("#results-table", DataTable)
cells = self._row_cells(test, status, duration_ms, detail) cells = self._row_cells(test, status, duration_ms, detail)
col_keys = [c.key for c in table.ordered_columns] new_height = self._row_height(detail)
for col_key, value in zip(col_keys, cells):
table.update_cell(test_id, col_key, value) # 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)
self._resort(table) self._resort(table)
def _row_cells( def _row_cells(
self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str self, test: TestInfo, status: TestStatus, duration_ms: int, detail: str
) -> tuple: ) -> tuple:
"""Собрать пять ячеек строки с учётом подсветки FAIL.""" """Собрать пять ячеек строки с учётом подсветки FAIL и переноса текста."""
status_text, status_color = _STATUS_DISPLAY[status] status_text, status_color = _STATUS_DISPLAY[status]
is_fail = status == TestStatus.FAIL is_fail = status == TestStatus.FAIL
@ -166,12 +212,17 @@ class ResultsPanel(Widget):
time_text = f"{duration_ms / 1000:.1f}с" if duration_ms > 0 else "" time_text = f"{duration_ms / 1000:.1f}с" if duration_ms > 0 else ""
hil_text = "[*]" if test.requires_hil else "[-]" hil_text = "[*]" if test.requires_hil else "[-]"
# overflow="fold" — перенос по символам на границе ячейки вместо
# обрезания с многоточием (Textual default), detail виден целиком
# на нескольких строках, если высота строки это позволяет.
detail_cell = Text(detail, style=detail_style, overflow="fold")
return ( return (
Text(test.name, style=name_style), Text(test.name, style=name_style),
Text(hil_text, style=hil_style), Text(hil_text, style=hil_style),
Text(status_text, style=status_style), Text(status_text, style=status_style),
Text(time_text, style=time_style), Text(time_text, style=time_style),
Text(detail, style=detail_style), detail_cell,
) )
def _resort(self, table: DataTable) -> None: def _resort(self, table: DataTable) -> None:

View file

@ -32,7 +32,7 @@ class TestListPanel(Widget):
self._hil_unavailable: dict[str, bool] = {} self._hil_unavailable: dict[str, bool] = {}
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Тесты", classes="section-title") yield Label("Доступные тесты", classes="section-title")
with Horizontal(id="test-list-select-row"): with Horizontal(id="test-list-select-row"):
yield Button( yield Button(
"Выбрать все", id="test-list-select-all", classes="-textual-compact" "Выбрать все", id="test-list-select-all", classes="-textual-compact"

View file

@ -31,16 +31,24 @@ from textual.widgets import (
from ..flasher import Flasher from ..flasher import Flasher
from ..models import FlashProgress, FlashTarget from ..models import FlashProgress, FlashTarget
from ..widgets import AppFrame from ..widgets import AppFrame
from .connection_watcher import ConnectionLost, ConnectionWatcherMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FlashScreen(Screen): class FlashScreen(Screen, ConnectionWatcherMixin):
""" """
Экран прошивки. Экран прошивки.
Messages: Messages:
FlashDone(success, target) прошивка завершена FlashDone(success, target) прошивка завершена
Мониторинг соединения: пока плата не прошивается (self._flashing
== False), каждые 1.5с проверяется наличие BootROM SDP на шине.
Если плата физически отключена в простое сессия считается
недостоверной, экран сразу уходит на WaitingScreen (см. замечание
4 отчёта). Во время самой прошивки/erase мониторинг приостановлен
обрыв в этом случае обнаружит и обработает сам flash_usb.py subprocess.
""" """
BINDINGS = [ BINDINGS = [
@ -61,12 +69,12 @@ class FlashScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with AppFrame(id="flash-frame"): with AppFrame(id="flash-frame"):
yield Label( yield Label(
"⚡ Загрузка прошивки на плату (BootROM SDP обнаружен)", "⚡ Загрузка прошивки на плату индикатора (режим BootROM)",
id="flash-title", id="flash-title",
) )
with Vertical(id="flash-target-group"): with Vertical(id="flash-target-group"):
yield Label("Выберите файл для загрузки", classes="section-title") yield Label("Выбор загружаемой прошивки", classes="section-title")
with RadioSet(id="flash-radio"): with RadioSet(id="flash-radio"):
yield RadioButton( yield RadioButton(
"Диагностическая прошивка (firmware_test)", "Диагностическая прошивка (firmware_test)",
@ -78,25 +86,47 @@ class FlashScreen(Screen):
id="radio-production", id="radio-production",
) )
yield RadioButton( yield RadioButton(
"Другое (подготовленный бинарный файл)", "Другое",
id="radio-custom", id="radio-custom",
) )
with Horizontal(id="flash-custom-path", classes="hidden"): with Horizontal(id="flash-custom-path", classes="hidden"):
yield Input( yield Input(
placeholder="Путь к бинарному файлу (.bin)", placeholder="Имя бинарного файла в custom_binaries/ (.bin)",
id="flash-custom-input", id="flash-custom-input",
) )
with Horizontal(id="flash-btn-row"): 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-erase", variant="error")
yield Button( 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) 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") @on(RadioSet.Changed, "#flash-radio")
@ -114,7 +144,7 @@ class FlashScreen(Screen):
return return
target, bin_path = self._resolve_target() target, bin_path = self._resolve_target()
if target is None: if target is None:
self._log("⚠ Укажите путь к бинарному файлу!") self._log("⚠ Укажите корректное имя бинарного файла")
return return
self._do_flash(target, bin_path) self._do_flash(target, bin_path)
@ -153,15 +183,11 @@ class FlashScreen(Screen):
async def _do_erase(self) -> None: async def _do_erase(self) -> None:
self._set_busy(True) self._set_busy(True)
self._show_progress(True) self._show_progress(True)
self._log("⚠ Очистка флеш памяти (~30 с)...") self._log("⚠ Очистка памяти (~30 с)...")
ok = await self._flasher.erase_chip(progress_cb=self._on_progress) ok = await self._flasher.erase_chip(progress_cb=self._on_progress)
self._set_busy(False) self._set_busy(False)
self._finish_progress(ok) self._finish_progress(ok)
self._log( self._log("✅ Очистка памяти завершена" if ok else "❌ Очистка памяти: ошибка")
"✅ Очистка флеш памяти завершена"
if ok
else "❌ Очистка флеш памяти: ошибка"
)
# ── Вспомогательные ─────────────────────────────────────────────────────── # ── Вспомогательные ───────────────────────────────────────────────────────

View file

@ -60,7 +60,9 @@ class PostFlashScreen(Screen):
with Horizontal(id="post-flash-btn-row"): with Horizontal(id="post-flash-btn-row"):
yield Button("✓ Готово", id="post-flash-btn-ok", variant="success") 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: def on_mount(self) -> None:
self._update_countdown() self._update_countdown()

View file

@ -3,10 +3,19 @@ waiting.py — экран ожидания подключения платы.
Опрашивает USB каждые _DETECT_INTERVAL_S секунд через Flasher. Опрашивает USB каждые _DETECT_INTERVAL_S секунд через Flasher.
При обнаружении SDP или CDC отправляет DeviceDetected message в App. При обнаружении SDP или CDC отправляет DeviceDetected message в App.
Принимает опциональный disconnect_reason короткое сообщение о причине
возврата на этот экран (например, "Соединение с платой потеряно"),
показывается несколько секунд поверх обычной подсказки, затем исчезает
само. Нужно, чтобы потеря USB во время прошивки/диагностики (см. отчёт,
замечание 4) не выглядела как необъяснимый скачок экрана сервисник
должен понимать, что это осознанное поведение, а не баг.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.message import Message from textual.message import Message
@ -20,6 +29,7 @@ from ..widgets import AppFrame
_DETECT_INTERVAL_S = 1.5 _DETECT_INTERVAL_S = 1.5
_SPIN_INTERVAL_S = 0.1 _SPIN_INTERVAL_S = 0.1
_REASON_DISPLAY_S = 4.0
_SPINNER_FRAMES = ["", "", "", "", "", "", "", "", "", ""] _SPINNER_FRAMES = ["", "", "", "", "", "", "", "", "", ""]
@ -38,27 +48,46 @@ class WaitingScreen(Screen):
super().__init__() super().__init__()
self.mode = mode self.mode = mode
def __init__(self, **kwargs) -> None: def __init__(self, disconnect_reason: Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._disconnect_reason = disconnect_reason
self._spinner_idx: int = 0 self._spinner_idx: int = 0
self._detect_timer: Timer | None = None self._detect_timer: Timer | None = None
self._spin_timer: Timer | None = None self._spin_timer: Timer | None = None
self._reason_timer: Timer | None = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with AppFrame(id="waiting-frame"): with AppFrame(id="waiting-frame"):
yield Static("TFT Indicator Board\nService Tool", id="waiting-logo") 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") yield Static(_SPINNER_FRAMES[0], id="waiting-spinner")
def on_mount(self) -> None: def on_mount(self) -> None:
self._detect_timer = self.set_interval(_DETECT_INTERVAL_S, self._poll_usb) self._detect_timer = self.set_interval(_DETECT_INTERVAL_S, self._poll_usb)
self._spin_timer = self.set_interval(_SPIN_INTERVAL_S, self._spin) 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: def on_unmount(self) -> None:
self._stop_timers() self._stop_timers()
# ── Internal ────────────────────────────────────────────────────────────── # ── 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: def _spin(self) -> None:
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES) self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
try: try:
@ -81,3 +110,5 @@ class WaitingScreen(Screen):
self._detect_timer.stop() self._detect_timer.stop()
if self._spin_timer: if self._spin_timer:
self._spin_timer.stop() self._spin_timer.stop()
if self._reason_timer:
self._reason_timer.stop()

View 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`, избыточно
---
## Закрытые архитектурные решения
> Не пересматривать без явного запроса.
### Этапы 15 (ранее зафиксированные)
- **Транспорт:** 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)
```

View file

@ -0,0 +1,7 @@
# TUI Service Tool Plan
1. Навести порядок с экранами "USB-POLL", "TRANSFER", возможно сделать bootlogo с версией программы и лого компании
2. Уменьшить размер активной области до предела и сделать его равным для всех экранов
3. Добавить обратный отчет в тесте buttons (таймаут)
4. Выгрузка результатов тестов в json файл с привязкой к UID микроконтроллера
5. Возможно ли копировать UID с экрана терминала?

View 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. В таблице тестов в столбце Детали при неудачном выполнение теста обрезается часть информации

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because it is too large Load diff