diff --git a/just/host.just b/just/host.just index 3064528..a80b9bf 100644 --- a/just/host.just +++ b/just/host.just @@ -269,44 +269,44 @@ _flash_swd := TOOLS_DIR / "flash_swd.py" [doc('Прошить firmware_test Debug через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-test-debug: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware firmware_test --build-type Debug [doc('Прошить firmware_test Release через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-test-release: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware firmware_test --build-type Release [doc('Прошить bootloader Debug через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-bootloader-debug: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware bootloader --build-type Debug [doc('Прошить bootloader Release через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-bootloader-release: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware bootloader --build-type Release [doc('Прошить tft_app Debug через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-app-debug: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware app --build-type Debug [doc('Прошить tft_app Release через SWD. Нужен power cycle после')] [group('flash-swd')] flash-swd-app-release: - uv run --directory {{ HIL_DIR }} python {{ _flash_swd }} \ + uv run --directory "{{ HIL_DIR }}" python "{{ _flash_swd }}" \ --firmware app --build-type Release [confirm("Стереть всю Flash (W25Q128)? Все прошивки будут удалены.")] [doc('Стереть всю Flash (W25Q128) через SWD (chip erase). Нужен power cycle после')] [group('flash-swd')] flash-swd-erase: - uv run --directory {{ HIL_DIR }} pyocd erase \ + uv run --directory "{{ HIL_DIR }}" pyocd erase \ --target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \ --frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \ --chip -O resume_on_disconnect=False @@ -321,59 +321,59 @@ _hil_build := BUILD_DIR / "target-debug" [doc('Запустить все не интерактивные HIL-тесты')] [group('hil')] hil-run: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest -m "not interactive and not usb_vcom" -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest -m "not interactive and not usb_vcom" -v [doc('Запустить все интерактивные HIL-тесты: кнопки, дисплей')] [group('hil')] hil-run-interactive: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest -m interactive -s -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest -m interactive -s -v [doc('Запустить HIL-тест UART')] [group('hil')] hil-uart: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest 01_test_uart.py -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest 01_test_uart.py -v [doc('Запустить HIL-тест оптоизолированных входов')] [group('hil')] hil-opto: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest 02_test_opto.py -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest 02_test_opto.py -v [doc('Запустить HIL-тест кнопок')] [group('hil')] hil-button: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest 04_test_button.py -s -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest 04_test_button.py -s -v [doc('Запустить HIL тест bsp_can')] [group('hil')] hil-can: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest 03_test_can.py -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest 03_test_can.py -v [doc('Запустить HIL-тест USB CDC')] [group('hil')] hil-usb-cdc: - HIL_BUILD_DIR={{ _hil_build }} \ - uv run --directory {{ HIL_DIR }} pytest 05_test_usb_cdc.py -v + HIL_BUILD_DIR="{{ _hil_build }}" \ + uv run --directory "{{ HIL_DIR }}" pytest 05_test_usb_cdc.py -v [doc('Запустить HIL-тест оптовходов через firmware_test CDC (протокол v2)')] [group('hil')] hil-firmware-opto: - uv run --directory {{ HIL_DIR }} pytest 06_test_firmware_opto.py -v + uv run --directory "{{ HIL_DIR }}" pytest 06_test_firmware_opto.py -v [doc('Запустить HIL-тест CAN через firmware_test CDC (протокол v2)')] [group('hil')] hil-firmware-can: - uv run --directory {{ HIL_DIR }} pytest 06_test_firmware_can.py -v + uv run --directory "{{ HIL_DIR }}" pytest 06_test_firmware_can.py -v [doc('Запустить все firmware_test CDC HIL-тесты (opto + can)')] [group('hil')] hil-firmware: - uv run --directory {{ HIL_DIR }} pytest 06_test_firmware_opto.py 06_test_firmware_can.py -v + uv run --directory "{{ HIL_DIR }}" pytest 06_test_firmware_opto.py 06_test_firmware_can.py -v # ============================================================================= # ГРУППА: debug — GDB-сервер для отладки из VSCode (devcontainer) @@ -394,8 +394,8 @@ _hil_dir := "tools/hil" [doc('Запустить GDB-сервер. Оставить запущенным, подключаться из VSCode')] [group('debug')] debug-server: - uv run --directory {{ _hil_dir }} pyocd gdbserver \ - --config {{ justfile_directory() }}/pyocd_debug.yaml \ + uv run --directory "{{ _hil_dir }}" pyocd gdbserver \ + --config "{{ justfile_directory() }}/pyocd_debug.yaml" \ --port {{ env('GDB_PORT', '3333') }} \ --persist \ --semihosting @@ -403,7 +403,7 @@ debug-server: [doc('Показать доступные builtin-таргеты pyOCD для MIMXRT')] [group('debug')] debug-list-targets: - uv run --directory {{ _hil_dir }} pyocd list --targets | grep -i mimx + uv run --directory "{{ _hil_dir }}" pyocd list --targets | grep -i mimx # ============================================================================= # ГРУППА: m5 — работа с M5Stack StamPLC @@ -448,7 +448,7 @@ m5-scan: [doc('Открыть MicroPython REPL на M5Stack (Ctrl+X для выхода)')] [group('m5')] m5-repl: - uv run --directory {{ HIL_DIR }} mpremote connect {{ M5_PORT }} repl + uv run --directory "{{ HIL_DIR }}" mpremote connect "{{ M5_PORT }}" repl [doc('Проверить версию MicroPython на M5Stack')] [group('m5')] @@ -470,8 +470,8 @@ m5-start: [doc('Управление питанием таргета через M5 RLY1: m5-power ')] [group('m5')] m5-power state: - uv run --directory {{ HIL_DIR }} python m5/power.py \ - --port {{ M5_PORT }} --state {{ state }} + uv run --directory "{{ HIL_DIR }}" python m5/power.py \ + --port "{{ M5_PORT }}" --state "{{ state }}" [doc('Скопировать агент на M5Stack (tools/hil/m5_agent.py → /main.py)')] [group('m5')] @@ -488,7 +488,7 @@ m5-deploy: [doc('Запустить интерактивный CLI для ручного тестирования агента')] [group('m5')] m5-cli: - uv run --directory {{ HIL_DIR }} python m5/cli.py --port {{ M5_PORT }} + uv run --directory "{{ HIL_DIR }}" python m5/cli.py --port "{{ M5_PORT }}" # ============================================================================= # ГРУППА: util @@ -513,8 +513,8 @@ flashloader-status: [doc('Открыть UART монитор на MCU-Link VCOM (кроссплатформенный)')] [group('util')] uart-monitor: - uv run --directory {{ HIL_DIR }} python3 -m serial.tools.miniterm \ - {{ env('HIL_VCOM_PORT') }} {{ env('HIL_VCOM_BAUD', '115200') }} \ + uv run --directory "{{ HIL_DIR }}" python3 -m serial.tools.miniterm \ + "{{ env('HIL_VCOM_PORT') }}" "{{ env('HIL_VCOM_BAUD', '115200') }}" \ --raw # ============================================================================= @@ -552,7 +552,7 @@ service-setup: [doc('Запустить TUI сервисного инженера')] [group('service')] service-tui: - uv run --directory {{ SERVICE_TUI_DIR }} python main.py + uv run --directory "{{ SERVICE_TUI_DIR }}" python main.py [doc('Собрать standalone-бинарь TUI (PyInstaller → dist/service_tui)')] [group('service')] diff --git a/logo-selected.png b/logo-selected.png deleted file mode 100644 index f28ae26..0000000 Binary files a/logo-selected.png and /dev/null differ diff --git a/tools/service_tui/README.md b/tools/service_tui/README.md index 864772c..c306d19 100644 --- a/tools/service_tui/README.md +++ b/tools/service_tui/README.md @@ -30,6 +30,12 @@ TUI-приложение для диагностики и прошивки пл Версия читается из `pyproject.toml` — при бампе версии мокап выше не нужно обновлять руками, TUI подставит актуальную сама. +После детекта платы подсказка меняется на «Плата найдена, подключаемся...», +спиннер продолжает крутиться — экран остаётся на месте ещё несколько секунд +(до ~10 с, если подключён HIL-стенд M5StampPLC и его агент только что +перезапустился при открытии порта), пока TUI подключается к плате и, +опционально, к M5. Это не зависание. + При потере соединения на любом другом экране сессия разрывается полностью — TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над подсказкой на 4 секунды появляется строка `⚠ <причина>` (например, @@ -208,6 +214,13 @@ Production/Custom этот шаг не нужен). - **Циклический прогон тестов** (повторный автозапуск набора без ручного нажатия на каждую итерацию) — отложен на пост-релиз, не входит в текущую версию. +- **Подключение к M5StampPLC (HIL-стенд) может занять до ~10 с** после + детекта платы, если M5 только что был подключён к USB или агент на нём + только что перезапустился (открытие serial-порта хостом перезапускает + MicroPython на M5 — агенту нужно время на инициализацию I2C/AW9523/CAN + перед готовностью отвечать). WaitingScreen в это время показывает + «Плата найдена, подключаемся...» — это штатное поведение, не зависание. + См. `docs/DEV_ARCH.md`, §10. --- diff --git a/tools/service_tui/app/app.py b/tools/service_tui/app/app.py index 80b44ed..955b2fa 100644 --- a/tools/service_tui/app/app.py +++ b/tools/service_tui/app/app.py @@ -7,6 +7,7 @@ app.py — корневое Textual приложение. from __future__ import annotations +import asyncio import logging import os from typing import Optional @@ -41,14 +42,33 @@ class ServiceApp(App): super().__init__() self._fw: Optional[FirmwareClient] = None self._m5: Optional[M5Client] = None + self._m5_task: Optional[asyncio.Task[Optional[M5Client]]] = None # «Липкий» выбор оператора на FlashScreen — переносится на следующую # плату в рамках одного запуска TUI (см. FlashPreset docstring). # Сбрасывается при перезапуске TUI, не персистится на диск. self._last_flash_preset = FlashPreset() def on_mount(self) -> None: + self._restart_m5_detection() self.push_screen(WaitingScreen()) + def _restart_m5_detection(self) -> None: + """ + Запустить поиск M5 фоновой задачей параллельно с WaitingScreen. + + M5 детектится не в момент перехода на диагностику (единственная + попытка без запаса по времени), а с момента входа на WaitingScreen — + столько же времени на «осесть» в ОС, сколько уже естественно есть у + целевой платы за счёт её непрерывного поллинга (см. m5_client.py). + """ + if self._m5_task is not None and not self._m5_task.done(): + self._m5_task.cancel() + self._m5_task = asyncio.create_task(M5Client.auto_connect()) + + def _switch_to_waiting(self, disconnect_reason: Optional[str] = None) -> None: + self._restart_m5_detection() + self.switch_screen(WaitingScreen(disconnect_reason=disconnect_reason)) + # ── Переходы между экранами ─────────────────────────────────────────────── @on(WaitingScreen.DeviceDetected) def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None: @@ -75,28 +95,25 @@ class ServiceApp(App): self._last_flash_preset = event.preset if event.target is None and not event.success: - self.switch_screen( - WaitingScreen( - disconnect_reason=event.error_message - or "Соединение с платой потеряно" - ) + self._switch_to_waiting( + disconnect_reason=event.error_message or "Соединение с платой потеряно" ) return if event.success and event.target == FlashTarget.FIRMWARE_TEST: self.switch_screen(PostFlashScreen()) else: - self.switch_screen(WaitingScreen()) + self._switch_to_waiting() @on(PostFlashScreen.Done) def _on_post_flash_done(self) -> None: """Оператор подтвердил смену BootMode или истёк таймаут.""" - self.switch_screen(WaitingScreen()) + self._switch_to_waiting() @on(DiagScreen.DiagDone) def _on_diag_done(self, event: DiagScreen.DiagDone) -> None: self._disconnect() - self.switch_screen(WaitingScreen(disconnect_reason=event.reason)) + self._switch_to_waiting(disconnect_reason=event.reason) # ── Подключение к firmware_test ─────────────────────────────────────────── @@ -106,10 +123,13 @@ class ServiceApp(App): self._fw = await FirmwareClient.auto_connect(vid=_CDC_VID, pid=_CDC_PID) except Exception as exc: logger.error("CDC connect failed: %s", exc) - self.switch_screen(WaitingScreen()) + self._switch_to_waiting() return - self._m5 = await M5Client.auto_connect() + # M5 детектится фоновой задачей с момента входа на WaitingScreen + # (см. _restart_m5_detection) — здесь просто забираем готовый + # результат, дожидаясь задачу, если она ещё не успела завершиться. + self._m5 = await self._m5_task if self._m5_task is not None else None fw_version = "" try: diff --git a/tools/service_tui/app/m5_client.py b/tools/service_tui/app/m5_client.py index 8ffe395..6d132d0 100644 --- a/tools/service_tui/app/m5_client.py +++ b/tools/service_tui/app/m5_client.py @@ -23,6 +23,7 @@ import os import asyncio import json import logging +import time from typing import Optional import serial @@ -36,6 +37,29 @@ _M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16) _READLINE_TIMEOUT_S = 0.5 _CMD_TIMEOUT_S = 3.0 +# Реальная причина «M5 не виден с первого запуска» (подтверждено логами +# с живого стенда, см. историю m5_client.py/app.py) — НЕ в детекте порта: +# serial.tools.list_ports.comports() находит M5 мгновенно, с первой же +# попытки. Ломается connect() ПОСЛЕ открытия порта: первый ping не получает +# ответ за _READLINE_TIMEOUT_S. Похоже, само открытие serial-порта хостом +# перезапускает MicroPython на M5 (типично для USB-CDC ESP32-S3), а +# agent.py (tools/hil/m5/agent.py) перед основным циклом делает I2C/AW9523/ +# CAN init и только потом пишет "READY" — на это уходит больше одного +# read-таймаута. На повторном запуске TUI (без переподключения M5) агент +# уже давно в основном цикле и отвечает мгновенно. +# +# Подтверждено логами: с бюджетом ~2.5с (6×0.5с) первый ping всё ещё не +# успевал (connect failed через 2.517с после найденного порта) — то есть +# перезагрузка/инициализация агента (I2C/AW9523/CAN) на живом стенде +# занимает заметно больше 2.5с. Бюджет увеличен с запасом; если снова не +# хватит — в логе будет видно сырое содержимое ответа (см. _send_recv), +# по нему можно будет откалибровать точнее вместо угадывания. +_AUTO_CONNECT_ATTEMPTS = 6 +_AUTO_CONNECT_RETRY_DELAY_S = 0.5 + +_CONNECT_PING_ATTEMPTS = 24 +_CONNECT_PING_RETRY_DELAY_S = 0.5 + def _find_m5_port() -> Optional[str]: """Автодетект M5StampPLC по VID/PID.""" @@ -45,6 +69,29 @@ def _find_m5_port() -> Optional[str]: return None +def _describe_visible_ports() -> str: + """ + Дамп всех видимых serial-портов (device + VID:PID) для диагностики. + + Нужен, чтобы при неудаче auto_connect() в логе было видно, была ли на + шине вообще хоть какая-то плата (и с каким VID:PID), а не просто + «ничего не найдено» без возможности отличить «M5 не подключён» от + «подключён, но не под тем VID:PID». + """ + try: + ports = list(serial.tools.list_ports.comports()) + except Exception as exc: + return f"" + if not ports: + return "<нет портов>" + return ", ".join( + f"{info.device} ({info.vid:04X}:{info.pid:04X})" + if info.vid is not None and info.pid is not None + else f"{info.device} (vid/pid=None)" + for info in ports + ) + + class M5Client: """ Async-клиент для M5StampPLC. @@ -72,9 +119,37 @@ class M5Client: Попытаться найти и подключиться к M5StampPLC. Вернуть None если не найден — M5 опционален. """ - port = _find_m5_port() + port = None + loop = asyncio.get_running_loop() + started = time.monotonic() + for attempt in range(1, _AUTO_CONNECT_ATTEMPTS + 1): + port = await loop.run_in_executor(None, _find_m5_port) + if port is not None: + elapsed = time.monotonic() - started + logger.info( + "M5StampPLC найден с попытки %d/%d (%.1fс, ищем %04X:%04X): %s", + attempt, + _AUTO_CONNECT_ATTEMPTS, + elapsed, + _M5_VID, + _M5_PID, + port, + ) + break + if attempt < _AUTO_CONNECT_ATTEMPTS: + await asyncio.sleep(_AUTO_CONNECT_RETRY_DELAY_S) if port is None: - logger.info("M5StampPLC не найден") + elapsed = time.monotonic() - started + visible = await loop.run_in_executor(None, _describe_visible_ports) + logger.info( + "M5StampPLC не найден за %.1fс (%d попыток, ищем %04X:%04X). " + "Видимые порты: %s", + elapsed, + _AUTO_CONNECT_ATTEMPTS, + _M5_VID, + _M5_PID, + visible, + ) return None client = cls(port=port, baudrate=baudrate) try: @@ -85,13 +160,30 @@ class M5Client: return None async def connect(self) -> None: - """Открыть порт и проверить связь через ping.""" + """ + Открыть порт и проверить связь через ping. + + Первый ping сразу после открытия порта нередко не долетает: судя по + логам (см. m5_client.py история), порт находится и открывается + мгновенно, но agent.py (tools/hil/m5/agent.py) перед тем как дойти + до основного цикла и ответить на первую команду, делает I2C/AW9523/ + CAN init и пишет "READY" — вероятно, само открытие serial-порта + хостом перезапускает MicroPython (типично для USB-CDC ESP32-S3), и + эта инициализация занимает больше одного read-таймаута. mpremote + (just host::m5-*) с этим не сталкивается — либо вообще не открывает + порт (m5-scan — чистое перечисление), либо переживает reset за счёт + своей протокольной логики поверх REPL. Здесь — короткий retry ping + вместо одной попытки с жёстким таймаутом. + """ loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._open) - ok = await self.ping() - if not ok: - await self.disconnect() - raise ConnectionError(f"M5StampPLC не отвечает: {self._port}") + for attempt in range(1, _CONNECT_PING_ATTEMPTS + 1): + if await self.ping(): + return + if attempt < _CONNECT_PING_ATTEMPTS: + await asyncio.sleep(_CONNECT_PING_RETRY_DELAY_S) + await self.disconnect() + raise ConnectionError(f"M5StampPLC не отвечает: {self._port}") def _open(self) -> None: self._ser = serial.Serial( @@ -124,10 +216,24 @@ class M5Client: self._ser.flush() raw = self._ser.readline() if not raw: + logger.debug( + "_send_recv(%s): нет ответа за %.1fс (timeout)", + cmd.get("cmd"), + _READLINE_TIMEOUT_S, + ) return None try: return json.loads(raw.decode("utf-8", errors="replace").strip()) except json.JSONDecodeError: + # Не JSON — вероятно boot-баннер MicroPython/REPL-вывод, если + # порт открылся во время перезапуска агента (см. connect()). + # Логируем сырую строку, чтобы это было видно, а не выглядело + # как немой таймаут. + logger.info( + "_send_recv(%s): не-JSON ответ, похоже на boot-вывод M5: %r", + cmd.get("cmd"), + raw[:200], + ) return None async def _cmd(self, cmd: dict) -> Optional[dict]: diff --git a/tools/service_tui/app/screens/waiting.py b/tools/service_tui/app/screens/waiting.py index 7b1a63f..864bfd0 100644 --- a/tools/service_tui/app/screens/waiting.py +++ b/tools/service_tui/app/screens/waiting.py @@ -139,12 +139,32 @@ class WaitingScreen(Screen): def _poll_usb(self) -> None: if Flasher.detect_sdp(): - self._stop_timers() + self._stop_detect_polling() self.post_message(self.DeviceDetected(AppMode.FLASHING)) elif Flasher.detect_cdc(): - self._stop_timers() + self._stop_detect_polling() self.post_message(self.DeviceDetected(AppMode.DIAGNOSING)) + def _stop_detect_polling(self) -> None: + """ + Остановить опрос USB, но не спиннер. + + Экран остаётся смонтированным ещё некоторое время после детекта — + ServiceApp подключается к плате и (в режиме диагностики) ждёт M5 + (см. app.py._connect_and_diagnose, ретрай ping в M5Client.connect() + может занимать секунды). Если остановить спиннер здесь же, экран + выглядит зависшим на этот промежуток — спиннер должен крутиться до + фактического переключения экрана (on_unmount). + """ + if self._detect_timer: + self._detect_timer.stop() + try: + self.query_one("#waiting-hint", Static).update( + "Плата найдена, подключаемся..." + ) + except NoMatches: + pass + def _stop_timers(self) -> None: if self._detect_timer: self._detect_timer.stop() diff --git a/tools/service_tui/docs/DEV_ARCH.md b/tools/service_tui/docs/DEV_ARCH.md index d39acc4..6171da5 100644 --- a/tools/service_tui/docs/DEV_ARCH.md +++ b/tools/service_tui/docs/DEV_ARCH.md @@ -510,6 +510,7 @@ sequenceDiagram participant M5 as M5StampPLC OP->>TUI: запустить service_tui + TUI->>M5: auto_connect() как фоновая задача TUI->>WS: push_screen() WS->>WS: spsdk/list_ports poll каждые 1.5 с @@ -517,7 +518,7 @@ sequenceDiagram WS->>TUI: DeviceDetected(DIAGNOSING) TUI->>FW: auto_connect() → ping→pong TUI->>FW: get_version() - TUI->>M5: auto_connect() (опционально) + TUI->>M5: await фоновую задачу (готова, либо ещё ретраит ping — см. ниже) TUI->>DS: switch_screen(fw_version=...) DS->>FW: list_tests() → TestInfo×N @@ -569,6 +570,14 @@ sequenceDiagram end ``` +M5 не детектится синхронно в момент перехода на диагностику — `ServiceApp._restart_m5_detection()` запускает `M5Client.auto_connect()` фоновой задачей (`asyncio.Task`) сразу при входе на `WaitingScreen` (включая `on_mount`), параллельно с поллингом целевой платы. `_connect_and_diagnose()` просто дожидается ту же задачу. + +**«M5 не виден с первого запуска TUI»** (воспроизведено и подтверждено логами с живого стенда, macOS) — это не проблема детекта порта: `serial.tools.list_ports.comports()` находит M5 мгновенно, с первой попытки (`_AUTO_CONNECT_ATTEMPTS = 6` в `m5_client.py` — небольшой запас на случай реальной гонки в перечислении портов, не более). Ломается `M5Client.connect()` сразу после открытия порта: первый `ping` не получает ответ за `_READLINE_TIMEOUT_S`. Судя по всему, само открытие serial-порта хостом перезапускает MicroPython на M5 (типично для USB-CDC ESP32-S3), а `agent.py` (`tools/hil/m5/agent.py`) перед основным циклом делает I2C/AW9523/CAN init и только потом пишет `"READY"` — на живом стенде это заняло больше 2.5с (бюджет первой версии фикса), поэтому итоговый бюджет ретрая `ping` — `_CONNECT_PING_ATTEMPTS = 24` (~12с, `_CONNECT_PING_RETRY_DELAY_S = 0.5`). Если сырой (не-JSON) ответ всё-таки прилетает во время этого окна, `_send_recv()` логирует его первые 200 байт на INFO — обычно это MicroPython boot-баннер, полезно для калибровки, если бюджета опять не хватит на другом экземпляре стенда. + +`just host::m5-*` с этой проблемой не сталкиваются: `m5-scan` вообще не открывает порт (чистое перечисление через `mpremote devs`), а `m5-repl`/`m5-cli`/etc используют `mpremote`, который переживает reset-on-connect за счёт своей протокольной логики поверх REPL — наш простой JSON-lines клиент такой логики не имеет, поэтому нужен явный retry. + +**Побочный эффект и его фикс:** пока `connect()` ретраит `ping` (до ~12с в худшем случае), `WaitingScreen` уже физически детектировал плату, но `ServiceApp` ещё не переключил экран — на месте секунд на 10 виден статичный (не крутящийся) спиннер, что выглядит как зависание. Причина — `_poll_usb()` в `waiting.py` останавливал разом все таймеры экрана, включая спиннер, в момент детекта. Исправлено: `_stop_detect_polling()` останавливает только опрос USB и меняет подсказку на «Плата найдена, подключаемся...», спиннер продолжает крутиться до фактического `switch_screen()` (весь набор таймеров глушится только в `on_unmount()`). + --- ## 11. Версионирование firmware