# Pre-release: stage 1 - service-tui m5 fix

This commit is contained in:
Dmitry Akimov 2026-07-08 15:01:48 +03:00
parent 6aaea215e1
commit 88c54b7aef
7 changed files with 222 additions and 54 deletions

View file

@ -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 <on|off>')]
[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')]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

View file

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

View file

@ -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:

View file

@ -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"<comports() failed: {exc}>"
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]:

View file

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

View file

@ -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