# Pre-release: stage 1 - service-tui m5 fix
This commit is contained in:
parent
6aaea215e1
commit
88c54b7aef
7 changed files with 222 additions and 54 deletions
|
|
@ -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 |
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue