# 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 после')] [doc('Прошить firmware_test Debug через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-test-debug: 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 --firmware firmware_test --build-type Debug
[doc('Прошить firmware_test Release через SWD. Нужен power cycle после')] [doc('Прошить firmware_test Release через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-test-release: 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 --firmware firmware_test --build-type Release
[doc('Прошить bootloader Debug через SWD. Нужен power cycle после')] [doc('Прошить bootloader Debug через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-bootloader-debug: 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 --firmware bootloader --build-type Debug
[doc('Прошить bootloader Release через SWD. Нужен power cycle после')] [doc('Прошить bootloader Release через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-bootloader-release: 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 --firmware bootloader --build-type Release
[doc('Прошить tft_app Debug через SWD. Нужен power cycle после')] [doc('Прошить tft_app Debug через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-app-debug: 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 --firmware app --build-type Debug
[doc('Прошить tft_app Release через SWD. Нужен power cycle после')] [doc('Прошить tft_app Release через SWD. Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-app-release: 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 --firmware app --build-type Release
[confirm("Стереть всю Flash (W25Q128)? Все прошивки будут удалены.")] [confirm("Стереть всю Flash (W25Q128)? Все прошивки будут удалены.")]
[doc('Стереть всю Flash (W25Q128) через SWD (chip erase). Нужен power cycle после')] [doc('Стереть всю Flash (W25Q128) через SWD (chip erase). Нужен power cycle после')]
[group('flash-swd')] [group('flash-swd')]
flash-swd-erase: flash-swd-erase:
uv run --directory {{ HIL_DIR }} pyocd erase \ uv run --directory "{{ HIL_DIR }}" pyocd erase \
--target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \ --target {{ env('PYOCD_TARGET', 'mimxrt1050_quadspi') }} \
--frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \ --frequency {{ env('PYOCD_FREQUENCY', '4000000') }} \
--chip -O resume_on_disconnect=False --chip -O resume_on_disconnect=False
@ -321,59 +321,59 @@ _hil_build := BUILD_DIR / "target-debug"
[doc('Запустить все не интерактивные HIL-тесты')] [doc('Запустить все не интерактивные HIL-тесты')]
[group('hil')] [group('hil')]
hil-run: hil-run:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest -m "not interactive and not usb_vcom" -v uv run --directory "{{ HIL_DIR }}" pytest -m "not interactive and not usb_vcom" -v
[doc('Запустить все интерактивные HIL-тесты: кнопки, дисплей')] [doc('Запустить все интерактивные HIL-тесты: кнопки, дисплей')]
[group('hil')] [group('hil')]
hil-run-interactive: hil-run-interactive:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest -m interactive -s -v uv run --directory "{{ HIL_DIR }}" pytest -m interactive -s -v
[doc('Запустить HIL-тест UART')] [doc('Запустить HIL-тест UART')]
[group('hil')] [group('hil')]
hil-uart: hil-uart:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest 01_test_uart.py -v uv run --directory "{{ HIL_DIR }}" pytest 01_test_uart.py -v
[doc('Запустить HIL-тест оптоизолированных входов')] [doc('Запустить HIL-тест оптоизолированных входов')]
[group('hil')] [group('hil')]
hil-opto: hil-opto:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest 02_test_opto.py -v uv run --directory "{{ HIL_DIR }}" pytest 02_test_opto.py -v
[doc('Запустить HIL-тест кнопок')] [doc('Запустить HIL-тест кнопок')]
[group('hil')] [group('hil')]
hil-button: hil-button:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest 04_test_button.py -s -v uv run --directory "{{ HIL_DIR }}" pytest 04_test_button.py -s -v
[doc('Запустить HIL тест bsp_can')] [doc('Запустить HIL тест bsp_can')]
[group('hil')] [group('hil')]
hil-can: hil-can:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest 03_test_can.py -v uv run --directory "{{ HIL_DIR }}" pytest 03_test_can.py -v
[doc('Запустить HIL-тест USB CDC')] [doc('Запустить HIL-тест USB CDC')]
[group('hil')] [group('hil')]
hil-usb-cdc: hil-usb-cdc:
HIL_BUILD_DIR={{ _hil_build }} \ HIL_BUILD_DIR="{{ _hil_build }}" \
uv run --directory {{ HIL_DIR }} pytest 05_test_usb_cdc.py -v uv run --directory "{{ HIL_DIR }}" pytest 05_test_usb_cdc.py -v
[doc('Запустить HIL-тест оптовходов через firmware_test CDC (протокол v2)')] [doc('Запустить HIL-тест оптовходов через firmware_test CDC (протокол v2)')]
[group('hil')] [group('hil')]
hil-firmware-opto: 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)')] [doc('Запустить HIL-тест CAN через firmware_test CDC (протокол v2)')]
[group('hil')] [group('hil')]
hil-firmware-can: 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)')] [doc('Запустить все firmware_test CDC HIL-тесты (opto + can)')]
[group('hil')] [group('hil')]
hil-firmware: 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) # ГРУППА: debug — GDB-сервер для отладки из VSCode (devcontainer)
@ -394,8 +394,8 @@ _hil_dir := "tools/hil"
[doc('Запустить GDB-сервер. Оставить запущенным, подключаться из VSCode')] [doc('Запустить GDB-сервер. Оставить запущенным, подключаться из VSCode')]
[group('debug')] [group('debug')]
debug-server: debug-server:
uv run --directory {{ _hil_dir }} pyocd gdbserver \ uv run --directory "{{ _hil_dir }}" pyocd gdbserver \
--config {{ justfile_directory() }}/pyocd_debug.yaml \ --config "{{ justfile_directory() }}/pyocd_debug.yaml" \
--port {{ env('GDB_PORT', '3333') }} \ --port {{ env('GDB_PORT', '3333') }} \
--persist \ --persist \
--semihosting --semihosting
@ -403,7 +403,7 @@ debug-server:
[doc('Показать доступные builtin-таргеты pyOCD для MIMXRT')] [doc('Показать доступные builtin-таргеты pyOCD для MIMXRT')]
[group('debug')] [group('debug')]
debug-list-targets: 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 # ГРУППА: m5 — работа с M5Stack StamPLC
@ -448,7 +448,7 @@ m5-scan:
[doc('Открыть MicroPython REPL на M5Stack (Ctrl+X для выхода)')] [doc('Открыть MicroPython REPL на M5Stack (Ctrl+X для выхода)')]
[group('m5')] [group('m5')]
m5-repl: 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')] [doc('Проверить версию MicroPython на M5Stack')]
[group('m5')] [group('m5')]
@ -470,8 +470,8 @@ m5-start:
[doc('Управление питанием таргета через M5 RLY1: m5-power <on|off>')] [doc('Управление питанием таргета через M5 RLY1: m5-power <on|off>')]
[group('m5')] [group('m5')]
m5-power state: m5-power state:
uv run --directory {{ HIL_DIR }} python m5/power.py \ uv run --directory "{{ HIL_DIR }}" python m5/power.py \
--port {{ M5_PORT }} --state {{ state }} --port "{{ M5_PORT }}" --state "{{ state }}"
[doc('Скопировать агент на M5Stack (tools/hil/m5_agent.py → /main.py)')] [doc('Скопировать агент на M5Stack (tools/hil/m5_agent.py → /main.py)')]
[group('m5')] [group('m5')]
@ -488,7 +488,7 @@ m5-deploy:
[doc('Запустить интерактивный CLI для ручного тестирования агента')] [doc('Запустить интерактивный CLI для ручного тестирования агента')]
[group('m5')] [group('m5')]
m5-cli: 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 # ГРУППА: util
@ -513,8 +513,8 @@ flashloader-status:
[doc('Открыть UART монитор на MCU-Link VCOM (кроссплатформенный)')] [doc('Открыть UART монитор на MCU-Link VCOM (кроссплатформенный)')]
[group('util')] [group('util')]
uart-monitor: uart-monitor:
uv run --directory {{ HIL_DIR }} python3 -m serial.tools.miniterm \ uv run --directory "{{ HIL_DIR }}" python3 -m serial.tools.miniterm \
{{ env('HIL_VCOM_PORT') }} {{ env('HIL_VCOM_BAUD', '115200') }} \ "{{ env('HIL_VCOM_PORT') }}" "{{ env('HIL_VCOM_BAUD', '115200') }}" \
--raw --raw
# ============================================================================= # =============================================================================
@ -552,7 +552,7 @@ service-setup:
[doc('Запустить TUI сервисного инженера')] [doc('Запустить TUI сервисного инженера')]
[group('service')] [group('service')]
service-tui: 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)')] [doc('Собрать standalone-бинарь TUI (PyInstaller → dist/service_tui)')]
[group('service')] [group('service')]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

View file

@ -30,6 +30,12 @@ TUI-приложение для диагностики и прошивки пл
Версия читается из `pyproject.toml` — при бампе версии мокап выше не нужно Версия читается из `pyproject.toml` — при бампе версии мокап выше не нужно
обновлять руками, TUI подставит актуальную сама. обновлять руками, TUI подставит актуальную сама.
После детекта платы подсказка меняется на «Плата найдена, подключаемся...»,
спиннер продолжает крутиться — экран остаётся на месте ещё несколько секунд
(до ~10 с, если подключён HIL-стенд M5StampPLC и его агент только что
перезапустился при открытии порта), пока TUI подключается к плате и,
опционально, к M5. Это не зависание.
При потере соединения на любом другом экране сессия разрывается полностью — При потере соединения на любом другом экране сессия разрывается полностью —
TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над TUI не пытается восстановить прежнее состояние, а возвращается сюда. Над
подсказкой на 4 секунды появляется строка `⚠ <причина>` (например, подсказкой на 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 from __future__ import annotations
import asyncio
import logging import logging
import os import os
from typing import Optional from typing import Optional
@ -41,14 +42,33 @@ class ServiceApp(App):
super().__init__() super().__init__()
self._fw: Optional[FirmwareClient] = None self._fw: Optional[FirmwareClient] = None
self._m5: Optional[M5Client] = None self._m5: Optional[M5Client] = None
self._m5_task: Optional[asyncio.Task[Optional[M5Client]]] = None
# «Липкий» выбор оператора на FlashScreen — переносится на следующую # «Липкий» выбор оператора на FlashScreen — переносится на следующую
# плату в рамках одного запуска TUI (см. FlashPreset docstring). # плату в рамках одного запуска TUI (см. FlashPreset docstring).
# Сбрасывается при перезапуске TUI, не персистится на диск. # Сбрасывается при перезапуске TUI, не персистится на диск.
self._last_flash_preset = FlashPreset() self._last_flash_preset = FlashPreset()
def on_mount(self) -> None: def on_mount(self) -> None:
self._restart_m5_detection()
self.push_screen(WaitingScreen()) 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) @on(WaitingScreen.DeviceDetected)
def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None: def _on_device_detected(self, event: WaitingScreen.DeviceDetected) -> None:
@ -75,28 +95,25 @@ class ServiceApp(App):
self._last_flash_preset = event.preset self._last_flash_preset = event.preset
if event.target is None and not event.success: if event.target is None and not event.success:
self.switch_screen( self._switch_to_waiting(
WaitingScreen( disconnect_reason=event.error_message or "Соединение с платой потеряно"
disconnect_reason=event.error_message
or "Соединение с платой потеряно"
)
) )
return 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:
self.switch_screen(WaitingScreen()) self._switch_to_waiting()
@on(PostFlashScreen.Done) @on(PostFlashScreen.Done)
def _on_post_flash_done(self) -> None: def _on_post_flash_done(self) -> None:
"""Оператор подтвердил смену BootMode или истёк таймаут.""" """Оператор подтвердил смену BootMode или истёк таймаут."""
self.switch_screen(WaitingScreen()) self._switch_to_waiting()
@on(DiagScreen.DiagDone) @on(DiagScreen.DiagDone)
def _on_diag_done(self, event: DiagScreen.DiagDone) -> None: def _on_diag_done(self, event: DiagScreen.DiagDone) -> None:
self._disconnect() self._disconnect()
self.switch_screen(WaitingScreen(disconnect_reason=event.reason)) self._switch_to_waiting(disconnect_reason=event.reason)
# ── Подключение к firmware_test ─────────────────────────────────────────── # ── Подключение к firmware_test ───────────────────────────────────────────
@ -106,10 +123,13 @@ class ServiceApp(App):
self._fw = await FirmwareClient.auto_connect(vid=_CDC_VID, pid=_CDC_PID) self._fw = await FirmwareClient.auto_connect(vid=_CDC_VID, pid=_CDC_PID)
except Exception as exc: except Exception as exc:
logger.error("CDC connect failed: %s", exc) logger.error("CDC connect failed: %s", exc)
self.switch_screen(WaitingScreen()) self._switch_to_waiting()
return 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 = "" fw_version = ""
try: try:

View file

@ -23,6 +23,7 @@ import os
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Optional from typing import Optional
import serial import serial
@ -36,6 +37,29 @@ _M5_PID = int(os.environ.get("SERVICE_M5_PID", "0x4001"), 16)
_READLINE_TIMEOUT_S = 0.5 _READLINE_TIMEOUT_S = 0.5
_CMD_TIMEOUT_S = 3.0 _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]: def _find_m5_port() -> Optional[str]:
"""Автодетект M5StampPLC по VID/PID.""" """Автодетект M5StampPLC по VID/PID."""
@ -45,6 +69,29 @@ def _find_m5_port() -> Optional[str]:
return None 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: class M5Client:
""" """
Async-клиент для M5StampPLC. Async-клиент для M5StampPLC.
@ -72,9 +119,37 @@ class M5Client:
Попытаться найти и подключиться к M5StampPLC. Попытаться найти и подключиться к M5StampPLC.
Вернуть None если не найден M5 опционален. Вернуть 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: 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 return None
client = cls(port=port, baudrate=baudrate) client = cls(port=port, baudrate=baudrate)
try: try:
@ -85,13 +160,30 @@ class M5Client:
return None return None
async def connect(self) -> 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() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._open) await loop.run_in_executor(None, self._open)
ok = await self.ping() for attempt in range(1, _CONNECT_PING_ATTEMPTS + 1):
if not ok: if await self.ping():
await self.disconnect() return
raise ConnectionError(f"M5StampPLC не отвечает: {self._port}") 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: def _open(self) -> None:
self._ser = serial.Serial( self._ser = serial.Serial(
@ -124,10 +216,24 @@ class M5Client:
self._ser.flush() self._ser.flush()
raw = self._ser.readline() raw = self._ser.readline()
if not raw: if not raw:
logger.debug(
"_send_recv(%s): нет ответа за %.1fс (timeout)",
cmd.get("cmd"),
_READLINE_TIMEOUT_S,
)
return None return None
try: try:
return json.loads(raw.decode("utf-8", errors="replace").strip()) return json.loads(raw.decode("utf-8", errors="replace").strip())
except json.JSONDecodeError: except json.JSONDecodeError:
# Не JSON — вероятно boot-баннер MicroPython/REPL-вывод, если
# порт открылся во время перезапуска агента (см. connect()).
# Логируем сырую строку, чтобы это было видно, а не выглядело
# как немой таймаут.
logger.info(
"_send_recv(%s): не-JSON ответ, похоже на boot-вывод M5: %r",
cmd.get("cmd"),
raw[:200],
)
return None return None
async def _cmd(self, cmd: dict) -> Optional[dict]: async def _cmd(self, cmd: dict) -> Optional[dict]:

View file

@ -139,12 +139,32 @@ class WaitingScreen(Screen):
def _poll_usb(self) -> None: def _poll_usb(self) -> None:
if Flasher.detect_sdp(): if Flasher.detect_sdp():
self._stop_timers() self._stop_detect_polling()
self.post_message(self.DeviceDetected(AppMode.FLASHING)) self.post_message(self.DeviceDetected(AppMode.FLASHING))
elif Flasher.detect_cdc(): elif Flasher.detect_cdc():
self._stop_timers() self._stop_detect_polling()
self.post_message(self.DeviceDetected(AppMode.DIAGNOSING)) 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: def _stop_timers(self) -> None:
if self._detect_timer: if self._detect_timer:
self._detect_timer.stop() self._detect_timer.stop()

View file

@ -510,6 +510,7 @@ sequenceDiagram
participant M5 as M5StampPLC participant M5 as M5StampPLC
OP->>TUI: запустить service_tui OP->>TUI: запустить service_tui
TUI->>M5: auto_connect() как фоновая задача
TUI->>WS: push_screen() TUI->>WS: push_screen()
WS->>WS: spsdk/list_ports poll каждые 1.5 с WS->>WS: spsdk/list_ports poll каждые 1.5 с
@ -517,7 +518,7 @@ sequenceDiagram
WS->>TUI: DeviceDetected(DIAGNOSING) WS->>TUI: DeviceDetected(DIAGNOSING)
TUI->>FW: auto_connect() → ping→pong TUI->>FW: auto_connect() → ping→pong
TUI->>FW: get_version() TUI->>FW: get_version()
TUI->>M5: auto_connect() (опционально) TUI->>M5: await фоновую задачу (готова, либо ещё ретраит ping — см. ниже)
TUI->>DS: switch_screen(fw_version=...) TUI->>DS: switch_screen(fw_version=...)
DS->>FW: list_tests() → TestInfo×N DS->>FW: list_tests() → TestInfo×N
@ -569,6 +570,14 @@ sequenceDiagram
end 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 ## 11. Версионирование firmware