diff --git a/.env.example b/.env.example index f96eebb..7efa4d3 100644 --- a/.env.example +++ b/.env.example @@ -48,11 +48,11 @@ HIL_VCOM_BAUD=115200 HIL_READY_TIMEOUT=5.0 # M5Stack StamPLC (промежуточная платформа для HIL) -HIL_M5_PORT=/dev/cu.usbmodem211101 +HIL_M5_PORT=/dev/cu.usbmodem11101 HIL_M5_BAUD=115200 HIL_M5_TIMEOUT=3.0 # VCOM на плате таргета -HIL_USB_CDC_PORT=/dev/cu.usbmodem21201 +HIL_USB_CDC_PORT=/dev/cu.usbmodem11301 HIL_USB_CDC_BAUD=115200 HIL_USB_CDC_TIMEOUT=5.0 \ No newline at end of file diff --git a/docs/testing/PROTOCOL.md b/docs/testing/PROTOCOL.md index cefa7c3..761c0f4 100644 --- a/docs/testing/PROTOCOL.md +++ b/docs/testing/PROTOCOL.md @@ -133,6 +133,48 @@ sequenceDiagram ← {"type":"summary","passed":7,"failed":0,"skipped":1,"overall":"pass"} ``` +### `list_tests` — получить список тестов + +Таргет возвращает реестр тестов с метаданными. TUI строит список +динамически на основе этого ответа, не хардкодит тесты. + +```json +→ {"type":"cmd","cmd":"list_tests"} +← {"type":"test_list","tests":[ + {"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false}, + {"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false}, + {"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false}, + {"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false}, + {"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false}, + {"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false}, + {"id":"can","name":"CAN loopback","critical":false,"requires_hil":true}, + {"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true} + ]} +``` + +### `run_selected` — запуск подмножества тестов + +Запускает тесты по списку ID. Порядок выполнения — по реестру таргета, +не по порядку в запросе. Таргет не фильтрует по `requires_hil` — +ответственность за фильтрацию HIL-тестов лежит на TUI. + +```json +→ {"type":"cmd","cmd":"run_selected","tests":["sdram","qspi","display"]} +← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true} +← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""} +← {"type":"test_begin","id":"qspi",...} +← {"type":"test_result","id":"qspi",...} +← {"type":"test_begin","id":"display",...} +← {"type":"test_result","id":"display",...} +← {"type":"summary","passed":3,"failed":0,"skipped":0,"overall":"pass"} +``` + +Если хотя бы один ID не найден в реестре — ни один тест не запускается: + +```json +← {"ok":false,"error":"UNKNOWN_TEST"} +``` + --- ## События таргета → хост @@ -204,13 +246,13 @@ sequenceDiagram ### Ошибки протокола -| Код | Причина | -| --------------- | ----------------------------------------------- | -| `PARSE_ERR` | Строка не является валидным JSON-lines запросом | -| `UNKNOWN_CMD` | Поле `"cmd"` содержит неизвестное значение | -| `UNKNOWN_TEST` | Поле `"id"` в `run` не найдено в реестре | -| `LINE_TOO_LONG` | Входящая строка превысила 128 байт | -| `BUSY` | Таргет выполняет тест, новая команда отклонена | +| Код | Причина | +| --------------- | -------------------------------------------------------------------------- | +| `PARSE_ERR` | Строка не является валидным JSON-lines запросом | +| `UNKNOWN_CMD` | Поле `"cmd"` содержит неизвестное значение | +| `UNKNOWN_TEST` | Поле `"id"` в `run` или `"tests"` в `run_selected` содержит неизвестный ID | +| `LINE_TOO_LONG` | Входящая строка превысила 128 байт | +| `BUSY` | Таргет выполняет тест, новая команда отклонена | --- @@ -358,8 +400,8 @@ const test_module_t k_test_foo = { }; ``` -3. Добавить `&k_test_foo` в реестр `test_runner.c`. -4. Добавить `tests/test_foo.c` в `CMakeLists.txt` таргета. +1. Добавить `&k_test_foo` в реестр `test_runner.c`. +2. Добавить `tests/test_foo.c` в `CMakeLists.txt` таргета. --- diff --git a/firmware/test/CMakeLists.txt b/firmware/test/CMakeLists.txt index e6e98db..8ed7628 100644 --- a/firmware/test/CMakeLists.txt +++ b/firmware/test/CMakeLists.txt @@ -11,7 +11,9 @@ add_executable( src/cli.c src/protocol.c src/test_runner.c + src/tests/test_opto.c src/tests/test_sdram.c + src/tests/test_can.c src/tests/test_qspi.c src/tests/test_usd.c src/tests/test_display.c @@ -38,6 +40,7 @@ target_compile_definitions( target_link_libraries( ${TARGET_NAME} PRIVATE bsp_board + bsp_can bsp_led bsp_button bsp_display @@ -46,6 +49,7 @@ target_link_libraries( bsp_usb_cdc bsp_sdram bsp_qspi_flash + bsp_opto bsp_sd firmware_test_fatfs) diff --git a/firmware/test/PLAN.md b/firmware/test/PLAN.md index a6304b2..79db34e 100644 --- a/firmware/test/PLAN.md +++ b/firmware/test/PLAN.md @@ -1,18 +1,19 @@ # firmware_test — План разработки -> Версия: 0.5 | Обновлён после завершения Этапа 5 (display + buttons). +> Версия: 0.6 | Обновлён после завершения Этапа 5 (display + buttons) и архитектурных решений по Этапам 6–8. --- ## Контекст проекта -**Цель прошивки:** входной контроль платы MIMXRT1052CVJ5B на производстве. +**Цель прошивки:** диагностика платы MIMXRT1052CVJ5B на сервисе (возврат по рекламации). Запускается через BootROM (USB SDP), без предварительной прошивки загрузчика. **Стенд:** + - Хост подключается через USB CDC ACM — единственный канал firmware_test -- Тесты с внешними сигналами управляются через M5StampPLC -- Сервер запускает `tools/hil/` (разработка) или `tools/production/` (производство) +- HIL-тесты управляются через M5StampPLC (опционально) +- TUI-приложение оркестрирует и firmware_test (CDC), и M5 (Serial) одновременно --- @@ -24,16 +25,38 @@ | firmware_test скелет | ✅ | `main.c` + `cli.c` | | Протокол v2 + test_runner | ✅ | JSON-lines event-driven | | `bsp_sdram` + `test_sdram` | ✅ | 4 фазы: addr/data/seq/retention | -| `bsp_qspi_flash` | ✅ | W25Q64/128/256/512, ITCM, IRQ lock | -| `test_qspi` | ✅ | JEDEC + erase + rw + addr range | +| `bsp_qspi_flash` + `test_qspi` | ✅ | JEDEC + erase + rw + addr range | | `bsp_sd` + `test_usd` | ✅ | bsp_sd + FatFS, pre_confirm, 4 шага | | `bsp_display` + `test_display` | ✅ | 4 цвета + ротация, hardware-verified | | `bsp_button` + `test_buttons` | ✅ | 2 кнопки, physical detect, hardware-verified | -| CAN test | ⬜ | Этап 6 (bsp_can ✅) | -| UART TTL test | ⬜ | Этап 6 (bsp_uart_host ✅) | -| UART ISO test | ⬜ | Этап 6 | -| Opto test | ⬜ | Этап 6 (bsp_opto ✅) | +| Протокол: `list_tests` | ⬜ | Этап 6а | +| Протокол: `run_selected` | ⬜ | Этап 6а | +| `test_opto` | ⬜ | Этап 6б (bsp_opto ✅) | +| `test_can` | ⬜ | Этап 6в (bsp_can ✅) | +| `bsp_mqs` + `test_mqs` | ⬜ | Этап 6г | +| HIL pytest firmware_cdc | ⬜ | Этап 6д | | Provisioning | ⬜ | Этап 7 | +| TUI сервисного инженера | ⬜ | Этап 8 | + +--- + +## Матрица тестов — итоговая + +| ID | Название | Critical | HIL | Тип | BSP | Статус | +| --------- | ------------------ | -------- | --- | ----------- | ------------------- | ------ | +| `sdram` | SDRAM 32 MB | ✅ | ❌ | self | `bsp_sdram` ✅ | ✅ | +| `qspi` | QSPI Flash W25Qxx | ✅ | ❌ | self | `bsp_qspi_flash` ✅ | ✅ | +| `usd` | microSD (SDIO) | ❌ | ❌ | interactive | `bsp_sd` ✅ | ✅ | +| `display` | TFT Display RGB888 | ❌ | ❌ | interactive | `bsp_display` ✅ | ✅ | +| `buttons` | Test Buttons 1/2 | ❌ | ❌ | interactive | `bsp_button` ✅ | ✅ | +| `mqs` | MQS Audio Out | ❌ | ❌ | interactive | `bsp_mqs` (⬜ новый) | ⬜ | +| `can` | CAN loopback | ❌ | ✅ | HIL | `bsp_can` ✅ | ⬜ | +| `opto` | Оптовходы IN1/2+RS | ❌ | ✅ | HIL | `bsp_opto` ✅ | ⬜ | + +**Убранные тесты (закрытые решения):** + +- `uart_ttl` — LPUART1 dev-инструмент (MCU-Link VCOM), в сервисе не используется +- `uart_iso` — RS_RX физически тот же пин что IN в `test_opto`, избыточно --- @@ -41,129 +64,210 @@ > Не пересматривать без явного запроса. +### Этапы 1–5 (ранее зафиксированные) + - **Транспорт:** USB CDC ACM — единственный канал. UART не используется в firmware_test. - **Парсинг JSON:** без cJSON, строковый `strstr`. Входящее поле всегда `"type"` / `"cmd"`. - **SDRAM и DCD:** SEMC инициализируется DCD до `main()`. `bsp_sdram_init()` только верифицирует. -- **SDRAM тест:** прогоняется командами через firmware_test, не отдельным HIL ELF. -- **QSPI-функции в ITCM:** `AT_QUICKACCESS_SECTION_CODE` + `__STARTUP_INITIALIZE_RAMFUNCTION` в CMakeLists. -- **QSPI IRQ lock:** `__get_PRIMASK()` + DSB/ISB. Публичные API под полным lock. +- **QSPI-функции в ITCM:** `AT_QUICKACCESS_SECTION_CODE` + `__STARTUP_INITIALIZE_RAMFUNCTION`. - **W25Q256/512:** dedicated 4-byte opcodes, без Enter 4-Byte Mode (0xB7). -- **Порядок init в main.c:** `bsp_qspi_init()` до `bsp_tick_init()`. -- **IR и RTC:** не реализуются. -- **Производственный runner:** Вариант D — отдельный `tools/production/` без pytest. - **bsp_button_init():** вызывается в `init()` тест-модуля, не в `main.c`. -- **Тест дисплея:** 4 цвета + 2 ротации (TFT ≠ TFT4). Таймаут confirm 15 с → FAIL. +- **Тест дисплея:** 4 цвета + 2 ротации. Таймаут confirm 15 с → FAIL. - **Тест кнопок:** физическая детекция через `bsp_button`. Хост не отправляет JSON confirm. Таймаут 10 с → SKIP. ---- +### Этап 6 (новые решения) -## Этап 4 — bsp_sd + test_usd ✅ +- **Разделение тестов:** `requires_hil=false` (standalone) vs `requires_hil=true` (HIL). + TUI фильтрует HIL-тесты если M5StampPLC не подключён. +- **`list_tests`:** таргет отдаёт реестр тестов с метаданными по запросу хоста. + TUI строит UI динамически, не хардкодит список тестов. +- **`run_selected`:** запуск произвольного подмножества тестов по списку ID. + Порядок выполнения — как в реестре таргета, не как в запросе. + Таргет принимает любой список без проверки `requires_hil` — ответственность на TUI. +- **TUI оркестрирует M5:** firmware_test не знает про M5. При `confirm_request` + от HIL-теста TUI командует M5, получает результат, отправляет confirm. +- **M5 опционален:** TUI при старте пробует найти M5. Не нашёл — HIL-тесты + недоступны (серые в UI, не входят в `run_selected`). +- **Фильтрация HIL на стороне TUI:** таргет не фильтрует по `requires_hil`. +- **MQS стерео:** MQS MIMXRT1052 требует стерео PCM-буфер. На плате выведен + один канал. Буфер всегда стерео (L+R идентичны). +- **MQS тест:** захардкоженная мелодия 3–5 с, `confirm_request("mqs_tone")`, + оператор слышит → OK/FAIL. `critical=false`, `requires_hil=false`. -### Аппаратный контекст +### Этап 8 (TUI решения) -| Параметр | Значение | -| ---------------- | -------------------------------------------- | -| Интерфейс | USDHC (SDIO) | -| Карта | microSD, вставляется оператором перед тестом | -| Файловая система | FatFS (SDK middleware) | -| Детект карты | `USDHC_GetPresentStatusFlags` | - -### Закрытые решения — Этап 4 - -- BSP-слой: `bsp_sd` (host init/deinit/card detect) + `firmware_test_fatfs` (FatFS). - `bsp_usd` как отдельный модуль не создавался — тест работает напрямую через `bsp_sd` + `ff.h`. -- Card detect: `bsp_sd_is_inserted()` через `USDHC_GetPresentStatusFlags`. GPIO-прерывание не используется. -- Карта вставляется оператором по запросу (pre_confirm). Между тестами может извлекаться. -- Drive `2:/`. Тестовый файл `2:/FWTEST.TMP`, удаляется в любом исходе (через `deinit()`). -- Паттерн: `byte[i] = i & 0xFF`, 4096 байт. -- `critical = false`: тест не блокирует HIL-тесты при отсутствии карты. -- Confirm timeout: 30 000 мс (`PROTOCOL_CONFIRM_TIMEOUT_MS`). -- `SD_HostInit` не вызывается в `bsp_sd_init()` — `sd_disk_initialize` делает полный init. Двойной init даёт `FR_NOT_READY`. -- Отдельный `test_usd.h` не создавался — `extern K_TEST_USD` объявлен в `test_runner.c`. +- **Прошивка — только USB SDP:** SWD недоступен сервиснику. spsdk (sdphost + blhost). + Оператор сам переставляет перемычку BOOT — это ок, документируется. +- **TUI автодетект:** определяет подключение по VID/PID — SDP BootROM (1FC9:0130) + или CDC firmware_test (session_start) — и показывает соответствующий экран. +- **Фреймворк TUI:** Textual (Python). Нативный async, реальные виджеты, + работает в SSH-сессии, вписывается в uv-экосистему. +- **tools/shared/m5_agent.py:** общая M5-логика, импортируется из `tools/hil/` + и `tools/production/`. --- -## Этап 5 — Display + Buttons ✅ +## Этап 6 — test_can + test_opto + test_mqs + протокол ← ТЕКУЩИЙ -### Аппаратный контекст кнопок +### 6а — Расширение протокола -| Кнопка | Пин MCU | GPIO | Схема | Нажатие | -| ---------- | ---------- | --------- | ----------------------------- | ------- | -| Test_But_1 | GPIO_B1_14 | GPIO2[30] | SWT6x6, pull-up к 3V3 внешний | LOW | -| Test_But_2 | GPIO_B1_15 | GPIO2[31] | SWT6x6, pull-up к 3V3 внешний | LOW | +**Файлы:** `protocol.h`, `protocol.c`, `cli.c`, `test_runner.c`, `PROTOCOL.md` -### Закрытые решения — test_display +#### Новая команда `list_tests` -- `pre_confirm_prompt = NULL` — нет pre-confirm, `test_begin` отправляется сразу. -- 6 шагов confirm: 4 цвета (Red/Green/Blue/White) + 2 ротации (только для TFT ≠ TFT4). -- Таймаут каждого confirm: 15 000 мс. Не подтверждён → FAIL с `detail = " not confirmed"`. -- Ротация: `ROTATE_0` + `FLIP_HORIZONTAL`. Восстановить `ROTATE_0` в любом исходе. -- Фреймбуфер: статический в NonCacheable SDRAM (`AT_NONCACHEABLE_SECTION_ALIGN`, 64-byte align). -- Тип дисплея: `DISPLAY_TEST_TYPE=BSP_DISPLAY_TFT8` через CMake compile definition. - -### Закрытые решения — test_buttons - -- `pre_confirm_prompt = NULL` — `confirm_request` используется только как UI-подсказка оператору. -- Хост **не** отправляет `{"type":"confirm",...}`. Детект нажатия — через `bsp_button_get_event_pressed()`. -- Таймаут: 10 000 мс → `TEST_STATUS_SKIP` (не FAIL). -- Порядок: But_1 → But_2. -- `bsp_button_poll()` вызывается каждые 5 мс через rate-limiting по `bsp_tick_get_ms()`. -- На каждом poll дренируются события **обеих** кнопок: предотвращает stale-событие от нецелевой кнопки. -- `bsp_button_init()` вызывается в `init()` тест-модуля — сброс debounce-счётчиков перед тестом. -- `bsp_tick_delay_ms()` не используется — polling pattern аналогичен `test_runner_wait_confirm()`. - ---- - -## Этап 6 — CAN + UART + Opto (HIL, M5StampPLC) ← ТЕКУЩИЙ - -Все три теста `requires_hil = true`. Запускаются только при наличии стенда. -BSP для всех трёх уже готов. - -### test_can - -M5StampPLC отправляет CAN-фрейм → плата принимает → сравниваем ID и payload. - -**Шаги:** -1. Таргет посылает `confirm_request` → M5 получает команду `can_send` -2. M5 → `{"cmd":"can_send","id":0x100,"data":[0xDE,0xAD,0xBE,0xEF]}` -3. Таргет: ожидает CAN-фрейм, 500 мс → верификация ID и payload -4. Ответный: таргет посылает → M5 `can_recv` → верификация - -### test_uart_ttl - -M5 loopback через UART TTL → echo-верификация. - -### test_uart_iso - -M5 RLY2 → RS_RX оптовход (BSP_OPTO_CH_RS) → детект ACTIVE/INACTIVE. -Использует `bsp_opto` с `rs_as_gpio=true`. - -### test_opto - -M5 RLY3/RLY4 → EXT_IN1/IN2 → детект ACTIVE/INACTIVE через `bsp_opto`. - -**Параметры стенда (из HIL_BENCH.md):** - -``` -RLY2 → RS_RX (BSP_OPTO_CH_RS) GPIO1[23] -RLY3 → EXT_IN1 (BSP_OPTO_CH_IN1) GPIO1[22] -RLY4 → EXT_IN2 (BSP_OPTO_CH_IN2) GPIO1[21] +```json +→ {"type":"cmd","cmd":"list_tests"} +← {"type":"test_list","tests":[ + {"id":"sdram","name":"SDRAM 32 MB","critical":true,"requires_hil":false}, + {"id":"qspi","name":"QSPI Flash W25Qxx","critical":true,"requires_hil":false}, + {"id":"usd","name":"microSD (SDIO)","critical":false,"requires_hil":false}, + {"id":"display","name":"TFT Display RGB888","critical":false,"requires_hil":false}, + {"id":"buttons","name":"Test Buttons","critical":false,"requires_hil":false}, + {"id":"mqs","name":"MQS Audio Out","critical":false,"requires_hil":false}, + {"id":"can","name":"CAN loopback","critical":false,"requires_hil":true}, + {"id":"opto","name":"Opto Inputs","critical":false,"requires_hil":true} + ]} ``` -### HIL pytest для Этапа 6 +#### Новая команда `run_selected` -Тесты firmware_test через USB CDC — отдельные от существующих HIL ELF тестов: - -``` -tools/hil/ -├── conftest.py ← добавить фикстуру firmware_cdc (USB CDC клиент) -├── 06_test_firmware_can.py ← M5 + USB CDC -├── 06_test_firmware_uart.py -└── 06_test_firmware_opto.py +```json +→ {"type":"cmd","cmd":"run_selected","tests":["sdram","qspi","display"]} +← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true} +← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""} +← {"type":"test_begin","id":"qspi",...} +← {"type":"test_result","id":"qspi",...} +← {"type":"test_begin","id":"display",...} +← {"type":"test_result","id":"display",...} +← {"type":"summary","passed":3,"failed":0,"skipped":0,"overall":"pass"} ``` -Фикстура `firmware_cdc` открывает CDC порт firmware_test (прошит в Flash), -посылает JSON команды, читает события. +Если хотя бы один ID не найден в реестре: + +```json +← {"ok":false,"error":"UNKNOWN_TEST"} +``` + +**Реализация в `test_runner.c`:** + +- Новый режим `RUNNER_MODE_SELECTED` +- Статический bool-массив `g_s_selected[REGISTRY_SIZE]` — без malloc +- `test_runner_run_selected(const char **pp_ids, size_t count)` — новая публичная функция + +### 6б — test_opto.c + +**Файл:** `firmware/test/src/tests/test_opto.c` + +6 шагов, попарно ACTIVE/INACTIVE для трёх каналов: + +| Шаг | confirm_request id | M5 действие | Проверка | +| --- | ------------------- | ----------- | -------------------------------- | +| 1 | `opto_in1_active` | RLY3 ON | `bsp_opto_read(IN1) == ACTIVE` | +| 2 | `opto_in1_inactive` | RLY3 OFF | `bsp_opto_read(IN1) == INACTIVE` | +| 3 | `opto_in2_active` | RLY4 ON | `bsp_opto_read(IN2) == ACTIVE` | +| 4 | `opto_in2_inactive` | RLY4 OFF | `bsp_opto_read(IN2) == INACTIVE` | +| 5 | `opto_rs_active` | RLY2 ON | `bsp_opto_read(RS) == ACTIVE` | +| 6 | `opto_rs_inactive` | RLY2 OFF | `bsp_opto_read(RS) == INACTIVE` | + +- Init: `bsp_opto_init()` единым вызовом для всех каналов +- Верификация синхронная после confirm (M5 переключил реле до отправки `confirmed:true`) +- FAIL при несоответствии: `detail = " state mismatch: expected ACTIVE got INACTIVE"` +- Таймаут: `PROTOCOL_CONFIRM_TIMEOUT_MS` (30 с) на каждый шаг + +### 6в — test_can.c + +**Файл:** `firmware/test/src/tests/test_can.c` + +2 шага, оба направления независимо: + +**Шаг 1 — RX (M5 → таргет):** + +```bash +confirm_request("can_rx_ready") +→ TUI: M5.can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF]) +→ TUI: confirm(true) +→ таргет: bsp_can_receive(&frame, 500 мс) +→ верификация: frame.id==0x100, frame.data==[0xDE,0xAD,0xBE,0xEF] +→ FAIL если timeout или несовпадение +``` + +**Шаг 2 — TX (таргет → M5):** + +```bash +bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс) +confirm_request("can_tx_verify") +→ TUI: M5.can_recv(timeout=500 мс) → верификация id+data +→ TUI: confirm(true) если M5 принял корректно, confirm(false) если нет +→ FAIL если confirmed=false или timeout +``` + +- `disableSelfReception=true` — таргет не слышит свой TX, только M5 верифицирует +- Init: `bsp_can_init(&cfg)` + `bsp_can_accept_all()` + +### 6г — bsp_mqs + test_mqs.c + +**Файлы:** `bsp/mqs/` (новый BSP-модуль) + `firmware/test/src/tests/test_mqs.c` + +**bsp_mqs:** + +- MQS требует стерео PCM (L+R), на плате один физический канал +- Буфер: всегда стерео (L == R, оба канала идентичны) +- API минимальный: `bsp_mqs_init()`, `bsp_mqs_play(buf, len)`, `bsp_mqs_stop()`, `bsp_mqs_deinit()` +- Реализация — на основе наработок (предоставит разработчик) + +**test_mqs:** +- Захардкоженная мелодия, 3–5 секунд +- `confirm_request("mqs_tone")` → оператор слышит → OK/FAIL +- Таймаут: 15 с (аналогично display) +- `critical=false`, `requires_hil=false`, `pre_confirm_prompt=NULL` + +### 6д — HIL pytest для firmware_test + +**Файлы:** +``` +tools/hil/conftest.py ← новая фикстура firmware_cdc +tools/hil/06_test_firmware_opto.py +tools/hil/06_test_firmware_can.py +``` + +**Фикстура `firmware_cdc`:** + +```python +@pytest.fixture(scope="module") +def firmware_cdc(m5): + """ + Открывает USB CDC порт firmware_test. + firmware_test уже прошит в Flash (не загружается pyOCD). + Ждёт session_start, возвращает FirmwareCdcClient. + """ +``` + +**`FirmwareCdcClient`** — тонкий клиент: + +- `send_cmd(cmd_dict)` — отправить JSON команду +- `wait_event(type, timeout_s)` — ждать события нужного типа +- `confirm(id, ok)` — отправить `{"type":"confirm","id":"...","confirmed":true/false}` +- `run_test(id)` — запустить тест, вернуть test_result dict + +**Сценарий `06_test_firmware_can.py`:** + +```python +def test_can_rx(firmware_cdc, m5): + # Запустить тест can через firmware_test + # При confirm_request("can_rx_ready") — M5 шлёт фрейм, затем confirm + ... + +def test_can_tx(firmware_cdc, m5): + # При confirm_request("can_tx_verify") — M5 принимает фрейм, верифицирует + ... +``` + +**Justfile:** + +```bash +hil-firmware-opto → pytest 06_test_firmware_opto.py -v +hil-firmware-can → pytest 06_test_firmware_can.py -v +``` --- @@ -171,10 +275,10 @@ tools/hil/ ### Что нужно -1. Читать `OCOTP_UNIQUE_ID` (или `OCOTP_MAC0/1`) через SDK fsl_ocotp. -2. Посылать `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary`. -3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста. -4. Записывать статус в Flash (первый сектор после прошивки, вне XIP). +1. Читать `OCOTP_UNIQUE_ID` через SDK `fsl_ocotp` +2. Отправить `{"type":"provision_ready","chip_uid":"AABB..."}` после `summary` +3. Ждать `{"type":"cmd","cmd":"provision_ack"}` от хоста +4. Записывать статус в Flash (первый сектор после прошивки, вне XIP) ### BSP (предварительно) @@ -190,45 +294,173 @@ bsp_status_t bsp_prov_read_uid(uint8_t *p_uid, size_t len); /* 8 байт из --- -## Матрица тестов — итоговая +## Этап 8 — TUI сервисного инженера -| ID | Название | Тип | Critical | HIL (M5) | BSP | Статус | -| ---------- | -------------- | ----------- | -------- | -------- | ------------------ | ------ | -| — | PING | cmd | — | ❌ | — | ✅ | -| `sdram` | SDRAM 32 MB | self | ✅ | ❌ | `bsp_sdram` ✅ | ✅ | -| `qspi` | QSPI Flash | self | ✅ | ❌ | `bsp_qspi_flash` ✅ | ✅ | -| `usd` | uSD (SDIO) | interactive | ❌ | ❌ | `bsp_sd` ✅ | ✅ | -| `display` | Display RGB888 | interactive | ❌ | ❌ | `bsp_display` ✅ | ✅ | -| `buttons` | Test_But_1/2 | interactive | ❌ | ❌ | `bsp_button` ✅ | ✅ | -| `can` | CAN loopback | HIL | ❌ | ✅ | `bsp_can` ✅ | ⬜ | -| `uart_ttl` | UART TTL | HIL | ❌ | ✅ | `bsp_uart_host` ✅ | ⬜ | -| `uart_iso` | UART ISO +24V | HIL | ❌ | ✅ | `bsp_opto` ✅ | ⬜ | -| `opto` | Opto-in EXT | HIL | ❌ | ✅ | `bsp_opto` ✅ | ⬜ | +### Стек технологий + +| Компонент | Выбор | Обоснование | +| ------------- | ------------- | ----------------------------------------------------- | +| TUI фреймворк | **Textual** | Нативный async, виджеты, SSH-совместим, uv-экосистема | +| Serial | pyserial | Уже в стеке (tools/hil) | +| Прошивка | spsdk | sdphost + blhost, уже в tools/host | +| Конфигурация | python-dotenv | .env файл, совместим с существующим подходом | + +### Структура приложения + +```bash +tools/production/ +├── pyproject.toml ← зависимости: textual, pyserial, spsdk, python-dotenv +├── uv.lock +├── main.py ← точка входа +├── app/ +│ ├── tui.py ← Textual App, экраны, layout +│ ├── firmware_client.py ← USB CDC asyncio клиент firmware_test +│ ├── m5_client.py ← M5 Serial клиент (импортирует tools/shared/m5_agent.py) +│ ├── flasher.py ← USB SDP обёртка над spsdk +│ ├── orchestrator.py ← confirm_request → M5 action → confirm response +│ └── models.py ← TestInfo, TestResult, SessionState (dataclasses) +└── README.md + +tools/shared/ +└── m5_agent.py ← общая M5-логика для hil/ и production/ +``` + +### Два режима работы + +**Режим A — Прошивка** (триггер: VID/PID 1FC9:0130 обнаружен — BootROM SDP) + +```bash +┌─ Прошивка платы ─────────────────────────────────┐ +│ Обнаружен BootROM (SDP режим) │ +│ │ +│ Что прошить? │ +│ ◉ firmware_test (диагностика) │ +│ ○ Production (bootloader + tft_app) │ +│ │ +│ Файл: [/path/to/firmware_test_hab.bin ···] │ +│ │ +│ [ Прошить ] │ +│ │ +│ ████████████░░░░░░ 64% Запись во Flash... │ +└────────────────────────────────────────────────────┘ +``` + +**Режим B — Диагностика** (триггер: session_start получен по CDC) + +```bash +┌─ Диагностика платы fw:0.1.0 ─────────────────────┐ +│ M5StampPLC: ✓ подключён │ Плата: IMXRT1052 │ +├────────────────────────────────────────────────────┤ +│ Выбор тестов: │ Результаты: │ +│ ☑ SDRAM 32 MB │ sdram ✓ PASS │ +│ ☑ QSPI Flash │ qspi ✓ PASS │ +│ ☑ microSD │ usd ✗ FAIL │ +│ ☑ TFT Display │ mount failed: 5 │ +│ ☑ Кнопки │ display ✓ PASS │ +│ ☑ MQS Audio │ buttons ✓ PASS │ +│ ☑ CAN loopback [HIL] │ ... │ +│ ☑ Оптовходы [HIL] │ │ +├────────────────────────────────────────────────────┤ +│ [ Запустить выбранные ] [ Все тесты ] │ +│ ████████████████░░░░ 80% Тест: display │ +├────────────────────────────────────────────────────┤ +│ ⚠ Экран залит красным цветом? │ +│ [ ✓ Да ] [ ✗ Нет ] │ +└────────────────────────────────────────────────────┘ +``` + +### Поведение confirm_request в TUI + +| Тип теста | Источник confirm | Действие TUI | +| -------------------- | ------------------ | --------------------------------------------- | +| standalone (display) | оператор | показать prompt, кнопки OK/FAIL, countdown | +| standalone (buttons) | физическое нажатие | показать инструкцию, ждать test_result | +| HIL (opto, can) | оркестратор | auto: M5 action → confirm (оператор не видит) | + +HIL confirm полностью автоматический — оператор видит только прогресс, не интерактивный prompt. + +### Конфигурация (.env) + +```ini +# Существующие переменные (tools/hil/.env): +HIL_VCOM_PORT=/dev/ttyACM0 +HIL_M5_PORT=/dev/ttyACM1 + +# Новые переменные для production TUI: +SERVICE_CDC_PORT=AUTO # AUTO = автодетект по session_start +SERVICE_M5_PORT=AUTO # AUTO = автодетект, пусто = без M5 +FIRMWARE_TEST_BIN=build/Release/firmware_test_hab.bin +PRODUCTION_BIN_BOOT=build/Release/bootloader_hab.bin +PRODUCTION_BIN_APP=build/Release/tft_app_hab.bin +``` + +### Запуск + +```bash +just host::service-tui # запустить TUI сервисного инженера +just host::service-flash # прошить без TUI (для автоматизации) +``` + +### Процесс работы сервисника + +**Диагностика (firmware_test уже в Flash):** + +```bash +1. Плата в нормальном режиме (BOOT_MOD_1 → GND) +2. Подключить USB к сервисному ПК +3. just host::service-tui → TUI обнаружил session_start → Режим B +4. Выбрать тесты → Запустить → Смотреть результаты +``` + +**Перепрошивка (нужна новая версия firmware_test или production):** +``` +1. Перемычка BOOT_MOD_1 → 3V3 +2. Reset, подключить USB +3. TUI обнаружил 1FC9:0130 → Режим A +4. Выбрать бинарь → Прошить +5. Перемычка BOOT_MOD_1 → GND → Reset → TUI переходит в Режим B +``` + +--- + +## Порядок реализации + +``` +✅ Этап 1 протокол v2 + runner +✅ Этап 2 bsp_sdram + test_sdram +✅ Этап 3 bsp_qspi_flash + test_qspi +✅ Этап 4 bsp_sd + test_usd +✅ Этап 5 display + buttons + +⬜ Этап 6а протокол: list_tests + run_selected ← СЛЕДУЮЩИЙ ШАГ +⬜ Этап 6б test_opto.c + hardware верификация +⬜ Этап 6в test_can.c + hardware верификация +⬜ Этап 6г bsp_mqs + test_mqs.c (после получения наработок) +⬜ Этап 6д HIL pytest: firmware_cdc фикстура +⬜ Этап 6е HIL pytest: 06_test_firmware_opto.py +⬜ Этап 6ж HIL pytest: 06_test_firmware_can.py + +⬜ Этап 7 Provisioning (OCOTP UID + Flash-флаг) + +⬜ Этап 8а tools/production/ скелет + models + clients +⬜ Этап 8б orchestrator + базовый Textual UI (список тестов, запуск, результаты) +⬜ Этап 8в Экран прошивки (flasher + SDP автодетект) +⬜ Этап 8г Provisioning в TUI +⬜ Этап 8д tools/shared/m5_agent.py (рефакторинг общей M5-логики) + +⬜ Этап 9 Параллельно: обновить README + DEV_ARCH.md под финальную архитектуру +``` --- ## Зависимости между этапами -``` -✅ Этап 1 (протокол v2 + runner) -✅ Этап 2 (bsp_sdram + test_sdram) -✅ Этап 3 (bsp_qspi_flash + test_qspi) -✅ Этап 4 (bsp_sd + test_usd) -✅ Этап 5 (display + buttons) -⬜ Этап 6 (CAN + UART + Opto, HIL) ← ТЕКУЩИЙ -⬜ Этап 7 (provisioning) -⬜ Этап 8 (tools/production/ TUI runner) ← параллельно с 6-7 -``` - ---- - -## Хостовое ПО производственного прогона (Этап 8) - -**Решение принято (Вариант D):** отдельное приложение `tools/production/`, без pytest, с TUI (Textual). - -### Открытые вопросы — Этап 8 - -- [ ] TUI: Textual или Rich или plain print на первой итерации? -- [ ] БД: SQLite локально или REST API? -- [ ] Несколько стендов параллельно или всегда один? -- [ ] Этикетка: нужна ли автоматическая печать после provisioning? \ No newline at end of file +```bash +6а (протокол) → 6б (opto) → 6в (can) → 6г (mqs) + ↓ + 6д (conftest) → 6е (opto pytest) → 6ж (can pytest) + ↓ + 7 (provisioning) + ↓ + 8 (TUI) +``` \ No newline at end of file diff --git a/firmware/test/src/cli.c b/firmware/test/src/cli.c index d398984..a8eefcd 100644 --- a/firmware/test/src/cli.c +++ b/firmware/test/src/cli.c @@ -36,7 +36,7 @@ static const char K_FIELD_TYPE[] = "\"type\""; static const char K_FIELD_CMD[] = "\"cmd\""; static const char K_FIELD_ID[] = "\"id\""; static const char K_FIELD_CONFIRMED[] = "\"confirmed\""; - +static const char K_FIELD_TESTS[] = "\"tests\""; /** @brief Буфер непрочитанного остатка chunk после вызова process_line(). */ static uint8_t g_s_chunk_buf[CLI_LINE_BUF_SIZE]; static size_t g_s_chunk_len = 0U; @@ -172,6 +172,63 @@ static bool parse_confirmed_field(const char *p_line, bool *p_out) return false; } +/** + * @brief Извлечь массив строковых значений из JSON-массива ["a","b","c"]. + * + * @param[in] p_array_start Указатель на символ '[' в строке. + * @param[out] pp_out Массив указателей на статические буферы. + * @param[out] p_out_bufs Двумерный буфер под строки. + * @param[in] max_items Максимальное число элементов. + * @param[in] item_max_len Максимальная длина каждого элемента (с NUL). + * @return Количество извлечённых элементов, или 0 при ошибке. + */ +static size_t parse_string_array(const char *p_array_start, char (*p_out_bufs)[TEST_ID_MAX_SIZE], + size_t max_items) +{ + const char *p = strchr(p_array_start, '['); + if (p == NULL) + { + return 0U; + } + p++; /* пропустить '[' */ + + size_t count = 0U; + while (count < max_items) + { + const char *open_q = strchr(p, '"'); + if (open_q == NULL) + { + break; + } + open_q++; + const char *close_q = strchr(open_q, '"'); + if (close_q == NULL) + { + break; + } + + size_t len = (size_t) (close_q - open_q); + if (len == 0U || len >= TEST_ID_MAX_SIZE) + { + return 0U; /* невалидный ID */ + } + + memcpy(p_out_bufs[count], open_q, len); + p_out_bufs[count][len] = '\0'; + count++; + + p = close_q + 1U; + /* Проверить конец массива */ + const char *bracket = strchr(p, ']'); + const char *next_q = strchr(p, '"'); + if (bracket != NULL && (next_q == NULL || bracket < next_q)) + { + break; + } + } + return count; +} + /* ── Обработчики входящих сообщений ────────────────────────────────────── */ /** @@ -191,6 +248,40 @@ static void handle_cmd_run(const char *p_line) test_runner_run_single(test_id); } +/** + * @brief Обработать команду "run_selected": распарсить массив ids и передать в test_runner. + */ +static void handle_cmd_run_selected(const char *p_line) +{ + /* Максимальный размер реестра — не более 16 тестов */ + const size_t MAX_SELECTED = 16U; + char id_bufs[16][TEST_ID_MAX_SIZE]; + const char *id_ptrs[16]; + + const char *tests_key = strstr(p_line, K_FIELD_TESTS); + if (tests_key == NULL) + { + protocol_send_error("PARSE_ERR"); + return; + } + + size_t count = + parse_string_array(tests_key + sizeof(K_FIELD_TESTS) - 1U, id_bufs, MAX_SELECTED); + + if (count == 0U) + { + protocol_send_error("PARSE_ERR"); + return; + } + + for (size_t i = 0U; i < count; i++) + { + id_ptrs[i] = id_bufs[i]; + } + + test_runner_run_selected(id_ptrs, count); +} + /** * @brief Обработать сообщение {"type":"cmd",...}. * @@ -225,6 +316,18 @@ static void handle_cmd(const char *p_line) return; } + if (strcmp(cmd_name, "list_tests") == 0) + { + test_runner_send_list(); + return; + } + + if (strcmp(cmd_name, "run_selected") == 0) + { + handle_cmd_run_selected(p_line); + return; + } + protocol_send_error("UNKNOWN_CMD"); } diff --git a/firmware/test/src/protocol.c b/firmware/test/src/protocol.c index e59e6ab..9342f05 100644 --- a/firmware/test/src/protocol.c +++ b/firmware/test/src/protocol.c @@ -11,7 +11,6 @@ #include #include #include - /* ── Константы ─────────────────────────────────────────────────────────── */ /** @@ -114,6 +113,28 @@ void protocol_send_confirm_request(const confirm_params_t *p_params) cli_send(buf); } +void protocol_send_test_list(const test_module_t *const *p_pp_registry, size_t count) +{ + /* Заголовок массива */ + cli_send("{\"type\":\"test_list\",\"tests\":["); + + for (size_t i = 0U; i < count; i++) + { + const test_module_t *mod = p_pp_registry[i]; + char buf[PROTO_BUF_SIZE]; + (void) snprintf(buf, sizeof(buf), + "{\"id\":\"%s\"," + "\"name\":\"%s\"," + "\"critical\":%s," + "\"requires_hil\":%s}%s", + mod->id, mod->name, mod->critical ? "true" : "false", + mod->requires_hil ? "true" : "false", (i + 1U < count) ? "," : ""); + cli_send(buf); + } + + cli_send("]}\n"); +} + void protocol_send_pong(void) { cli_send("{\"type\":\"pong\"}\n"); diff --git a/firmware/test/src/protocol.h b/firmware/test/src/protocol.h index dc3153d..9f59d24 100644 --- a/firmware/test/src/protocol.h +++ b/firmware/test/src/protocol.h @@ -21,8 +21,8 @@ #include "test_module.h" #include +#include #include - /** @brief Строка версии прошивки, вставляемая в session_start. */ #define FIRMWARE_TEST_VERSION "0.1.4" @@ -74,6 +74,16 @@ void protocol_send_confirm_request(const confirm_params_t *p_params); */ void protocol_send_pong(void); +/** + * @brief Отправить список тестов из реестра (ответ на команду list_tests). + * + * Формат: {"type":"test_list","tests":[{"id":"...","name":"...","critical":...,"requires_hil":...},...]} + * + * @param[in] pp_registry Указатель на массив указателей на дескрипторы тестов. + * @param[in] count Размер реестра. + */ +void protocol_send_test_list(const test_module_t *const *p_pp_registry, size_t count); + /** * @brief Отправить событие error. * diff --git a/firmware/test/src/test_runner.c b/firmware/test/src/test_runner.c index 130ed0f..43bd63f 100644 --- a/firmware/test/src/test_runner.c +++ b/firmware/test/src/test_runner.c @@ -30,10 +30,13 @@ extern const test_module_t K_TEST_QSPI; extern const test_module_t K_TEST_USD; extern const test_module_t K_TEST_DISPLAY; extern const test_module_t K_TEST_BUTTONS; +extern const test_module_t K_TEST_OPTO; +extern const test_module_t K_TEST_CAN; static const test_module_t *const k_registry[] = { - &K_TEST_SDRAM, &K_TEST_QSPI, &K_TEST_USD, &K_TEST_DISPLAY, &K_TEST_BUTTONS, + &K_TEST_SDRAM, &K_TEST_QSPI, &K_TEST_USD, &K_TEST_DISPLAY, + &K_TEST_BUTTONS, &K_TEST_OPTO, &K_TEST_CAN, }; #define REGISTRY_SIZE (sizeof(k_registry) / sizeof(k_registry[0])) @@ -67,6 +70,7 @@ typedef enum runner_mode_e { RUNNER_MODE_SINGLE, RUNNER_MODE_ALL, + RUNNER_MODE_SELECTED, } runner_mode_t; /* ── Статическое состояние ─────────────────────────────────────────────── */ @@ -86,6 +90,9 @@ static uint8_t g_s_failed; static uint8_t g_s_skipped; static bool g_s_critical_failed; +/* Маска выбранных тестов для RUNNER_MODE_SELECTED */ +static bool g_s_selected[REGISTRY_SIZE]; + /* ── Forward declaration ───────────────────────────────────────────────── */ static void start_test_at(size_t idx); @@ -199,6 +206,10 @@ static void skip_from(size_t from_idx) { for (size_t i = from_idx; i < REGISTRY_SIZE; i++) { + if (g_s_mode == RUNNER_MODE_SELECTED && !g_s_selected[i]) + { + continue; + } const test_module_t *mod = k_registry[i]; test_result_t result = make_skip_result("critical test failed"); protocol_send_test_begin(mod); @@ -222,7 +233,18 @@ static void advance_after_current(void) return; } - g_s_current_idx++; + /* Найти следующий активный индекс */ + size_t next_idx = g_s_current_idx + 1U; + + if (g_s_mode == RUNNER_MODE_SELECTED) + { + while (next_idx < REGISTRY_SIZE && !g_s_selected[next_idx]) + { + next_idx++; + } + } + + g_s_current_idx = next_idx; if (g_s_current_idx >= REGISTRY_SIZE) { @@ -413,4 +435,63 @@ bool test_runner_wait_confirm(const confirm_params_t *p_params) } return g_s_confirm_value; +} + +void test_runner_send_list(void) +{ + protocol_send_test_list(k_registry, REGISTRY_SIZE); +} + +void test_runner_run_selected(const char *const *p_pp_ids, size_t count) +{ + if (g_s_state != RUNNER_STATE_IDLE) + { + protocol_send_error("BUSY"); + return; + } + + /* Проверить все ID до начала выполнения */ + for (size_t i = 0U; i < count; i++) + { + if (find_test_by_id(p_pp_ids[i]) >= REGISTRY_SIZE) + { + protocol_send_error("UNKNOWN_TEST"); + return; + } + } + + /* Сбросить маску выбора */ + for (size_t i = 0U; i < REGISTRY_SIZE; i++) + { + g_s_selected[i] = false; + } + + /* Заполнить маску */ + for (size_t i = 0U; i < count; i++) + { + size_t idx = find_test_by_id(p_pp_ids[i]); + g_s_selected[idx] = true; + } + + g_s_passed = 0U; + g_s_failed = 0U; + g_s_skipped = 0U; + g_s_critical_failed = false; + g_s_mode = RUNNER_MODE_SELECTED; + + /* Найти первый выбранный тест */ + size_t first_idx = 0U; + while (first_idx < REGISTRY_SIZE && !g_s_selected[first_idx]) + { + first_idx++; + } + + if (first_idx >= REGISTRY_SIZE) + { + /* Пустой список — summary с нулями */ + protocol_send_summary(0U, 0U, 0U, true); + return; + } + + start_test_at(first_idx); } \ No newline at end of file diff --git a/firmware/test/src/test_runner.h b/firmware/test/src/test_runner.h index cb7ffb3..20918a7 100644 --- a/firmware/test/src/test_runner.h +++ b/firmware/test/src/test_runner.h @@ -17,6 +17,7 @@ #include "test_module.h" #include +#include /** * @brief Инициализировать runner. Сбрасывает состояние в IDLE и обнуляет счётчики. @@ -86,4 +87,25 @@ bool test_runner_is_busy(void); */ bool test_runner_wait_confirm(const confirm_params_t *p_params); +/** + * @brief Запустить выбранное подмножество тестов по списку ID. + * + * Порядок выполнения — по реестру таргета, не по порядку в pp_ids. + * Если хотя бы один ID не найден в реестре — отправляет UNKNOWN_TEST и не запускает ничего. + * Если runner занят — отправляет BUSY. + * После завершения всех выбранных тестов отправляет summary. + * + * @param[in] pp_ids Массив C-строк с идентификаторами тестов. + * @param[in] count Количество элементов в pp_ids. + */ +void test_runner_run_selected(const char *const *p_pp_ids, size_t count); + +/** + * @brief Отправить список всех зарегистрированных тестов хосту. + * + * Вызывается из cli.c при получении команды list_tests. + * Проксирует реестр в protocol_send_test_list(). + */ +void test_runner_send_list(void); + #endif /* TEST_RUNNER_H_ */ \ No newline at end of file diff --git a/firmware/test/src/tests/test_can.c b/firmware/test/src/tests/test_can.c new file mode 100644 index 0000000..3b51299 --- /dev/null +++ b/firmware/test/src/tests/test_can.c @@ -0,0 +1,209 @@ +/* + * test_can — HIL-тест CAN-интерфейса (FlexCAN2, трансивер SN65HVD230D) + * + * 2 шага, оба направления независимо: + * + * Шаг 1 — RX (M5 → таргет): + * confirm_request("can_rx_ready") + * TUI командует M5: can_send(id=0x100, data=[0xDE,0xAD,0xBE,0xEF]) + * TUI отправляет confirm(true) + * таргет: bsp_can_receive(&frame, 500 мс) + * верификация: frame.id == 0x100, frame.data == [0xDE,0xAD,0xBE,0xEF] + * + * Шаг 2 — TX (таргет → M5): + * таргет: bsp_can_send(id=0x200, data=[0xCA,0xFE,0xBA,0xBE], timeout=100 мс) + * confirm_request("can_tx_verify") + * TUI: M5.can_recv(timeout=500 мс) → проверяет id+data + * TUI: confirm(true) если корректно, confirm(false) если нет + * + * disableSelfReception=true в bsp_can — таргет не слышит свой TX, + * верификацию TX делает только M5. + */ + +#include "bsp/can.h" +#include "bsp/tick.h" +#include "bsp/usb_cdc.h" +#include "protocol.h" +#include "test_module.h" +#include "test_runner.h" + +#include +#include +#include +/* ── Константы ───────────────────────────────────────────────────────────── */ + +/** Битрейт CAN, совпадает с конфигурацией агента M5. */ +#define CAN_BITRATE 125000U + +/** Таймаут TX в bsp_can_send(), мс. */ +#define CAN_TX_TIMEOUT_MS 100U + +/** + * Таймаут bsp_can_receive() после confirm от M5, мс. + * M5 уже отправил фрейм до confirmed:true — 500 мс с большим запасом. + */ +#define CAN_RX_TIMEOUT_MS 500U + +/** ID фрейма M5 → таргет (шаг 1). */ +#define CAN_RX_EXPECTED_ID 0x100U + +/** ID фрейма таргет → M5 (шаг 2). */ +#define CAN_TX_ID 0x200U + +/** Данные фрейма M5 → таргет (шаг 1). */ +static const uint8_t K_RX_EXPECTED_DATA[] = { 0xDEU, 0xADU, 0xBEU, 0xEFU }; + +/** Данные фрейма таргет → M5 (шаг 2). */ +static const uint8_t K_TX_DATA[] = { 0xCAU, 0xFEU, 0xBAU, 0xBEU }; + +#define CAN_FRAME_DLC 4U + +/* ── Реализация тест-модуля ──────────────────────────────────────────────── */ + +/** + * @brief Инициализация: 125 kbit/s, принимать все фреймы. + */ +static void can_init(void) +{ + const bsp_can_config_t k_cfg = { .bitrate = CAN_BITRATE }; + (void) bsp_can_init(&k_cfg); + (void) bsp_can_accept_all(); +} + +/** + * @brief Выполнение теста: шаг 1 (RX) + шаг 2 (TX). + * + * @return test_result_t + */ +static test_result_t can_run(void) +{ + test_result_t result = { + .status = TEST_STATUS_PASS, + .duration_ms = 0U, + .detail = "", + }; + + /* ── Шаг 1: RX (M5 → таргет) ─────────────────────────────────────── */ + + /* + * Запросить у TUI: пусть M5 отправит тестовый фрейм. + * M5 отправляет фрейм ДО того как подтвердить confirm(true). + * После confirm таргет вызывает bsp_can_receive(). + */ + const confirm_params_t k_rx_params = { + .id = "can_rx_ready", + .prompt = "M5: can_send id=0x100 data=[DE AD BE EF]", + .timeout_ms = 0U, + }; + + bool confirmed = test_runner_wait_confirm(&k_rx_params); + + if (!confirmed) + { + result.status = TEST_STATUS_SKIP; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "confirm timeout on can_rx_ready"); + return result; + } + + /* Принять фрейм — M5 уже отправил его до confirmed:true */ + bsp_can_frame_t rx_frame; + bsp_status_t rx_status = bsp_can_receive(&rx_frame, CAN_RX_TIMEOUT_MS); + + if (rx_status != BSP_OK) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "can_rx_ready: no frame received"); + return result; + } + + /* Верификация ID */ + if (rx_frame.id != CAN_RX_EXPECTED_ID) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, + "rx id mismatch: expected 0x%03lX got 0x%03lX", + (unsigned long) CAN_RX_EXPECTED_ID, (unsigned long) rx_frame.id); + return result; + } + + /* Верификация DLC */ + if (rx_frame.dlc != CAN_FRAME_DLC) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "rx dlc mismatch: expected %u got %u", + (unsigned) CAN_FRAME_DLC, (unsigned) rx_frame.dlc); + return result; + } + + /* Верификация данных */ + if (memcmp(rx_frame.data, K_RX_EXPECTED_DATA, CAN_FRAME_DLC) != 0) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, + "rx data mismatch: got %02X %02X %02X %02X", (unsigned) rx_frame.data[0], + (unsigned) rx_frame.data[1], (unsigned) rx_frame.data[2], + (unsigned) rx_frame.data[3]); + return result; + } + + /* ── Шаг 2: TX (таргет → M5) ─────────────────────────────────────── */ + + /* + * Сначала отправляем фрейм — до confirm_request. + * TUI увидит confirm_request, скажет M5 принять фрейм, + * проверит id+data и пришлёт confirm(true/false). + */ + bsp_can_frame_t tx_frame; + (void) memset(&tx_frame, 0, sizeof(tx_frame)); + tx_frame.id = CAN_TX_ID; + tx_frame.dlc = CAN_FRAME_DLC; + tx_frame.is_extended = false; + tx_frame.is_remote = false; + (void) memcpy(tx_frame.data, K_TX_DATA, CAN_FRAME_DLC); + + bsp_status_t tx_status = bsp_can_send(&tx_frame, CAN_TX_TIMEOUT_MS); + + if (tx_status != BSP_OK) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "tx failed: bsp_can_send returned %d", + (int) tx_status); + return result; + } + + /* + * Запросить верификацию у TUI: M5 должен был принять наш фрейм. + * TUI проверяет id+data и шлёт confirmed(true) или confirmed(false). + */ + const confirm_params_t k_tx_params = { + .id = "can_tx_verify", + .prompt = "M5: verify can_recv id=0x200 data=[CA FE BA BE]", + .timeout_ms = 0U, + }; + + confirmed = test_runner_wait_confirm(&k_tx_params); + + if (!confirmed) + { + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, + "can_tx_verify: M5 did not confirm tx frame"); + return result; + } + + return result; +} + +/* ── Дескриптор тест-модуля ──────────────────────────────────────────────── */ + +/** @brief Дескриптор для регистрации в test_runner. */ +const test_module_t K_TEST_CAN = { + .id = "can", + .name = "CAN loopback", + .critical = false, + .requires_hil = true, + .pre_confirm_prompt = NULL, + .init = can_init, + .run = can_run, + .deinit = NULL, +}; \ No newline at end of file diff --git a/firmware/test/src/tests/test_opto.c b/firmware/test/src/tests/test_opto.c new file mode 100644 index 0000000..c9c713f --- /dev/null +++ b/firmware/test/src/tests/test_opto.c @@ -0,0 +1,193 @@ +/* + * test_opto — HIL-тест оптоизолированных входов (PS2801-4) + * + * Три канала: EXT_IN1 (RLY3), EXT_IN2 (RLY4), RS_RX (RLY2). + * Все три — MODE_LEVEL, rs_as_gpio = true. + * + * 6 шагов: confirm_request → M5 переключает реле → settle → bsp_opto_read(). + * Верификация синхронная: M5 переключил реле до отправки confirmed:true. + */ +#include "bsp/opto.h" +#include "bsp/tick.h" +#include "bsp/usb_cdc.h" +#include "protocol.h" +#include "test_module.h" +#include "test_runner.h" + +#include +#include +#include +#include +/* ── Константы ───────────────────────────────────────────────────────────── */ + +/** Период дебаунса оптовходов, мс. */ +#define OPTO_DEBOUNCE_MS 10U + +/** + * Пауза после confirm перед bsp_opto_read(), мс. + * Должна перекрывать OPTO_DEBOUNCE_MS с запасом на jitter реле. + * M5 уже переключил реле до отправки confirmed:true. + */ +#define OPTO_SETTLE_MS 30U + +/* ── Описание одного шага ────────────────────────────────────────────────── */ + +typedef struct +{ + const char *p_confirm_id; /**< id для confirm_request / confirm. */ + const char *p_prompt; /**< Инструкция для TUI / M5. */ + bsp_opto_ch_t channel; /**< Канал для верификации. */ + bsp_opto_state_t expected_state; /**< Ожидаемое состояние. */ +} opto_step_t; + +/* ── Таблица шагов (PLAN.md §6б) ─────────────────────────────────────────── */ + +static const opto_step_t K_STEPS[] = { + { + .p_confirm_id = "opto_in1_active", + .p_prompt = "M5: RLY3 ON -> IN1 ACTIVE", + .channel = BSP_OPTO_CH_IN1, + .expected_state = BSP_OPTO_STATE_ACTIVE, + }, + { + .p_confirm_id = "opto_in1_inactive", + .p_prompt = "M5: RLY3 OFF -> IN1 INACTIVE", + .channel = BSP_OPTO_CH_IN1, + .expected_state = BSP_OPTO_STATE_INACTIVE, + }, + { + .p_confirm_id = "opto_in2_active", + .p_prompt = "M5: RLY4 ON -> IN2 ACTIVE", + .channel = BSP_OPTO_CH_IN2, + .expected_state = BSP_OPTO_STATE_ACTIVE, + }, + { + .p_confirm_id = "opto_in2_inactive", + .p_prompt = "M5: RLY4 OFF -> IN2 INACTIVE", + .channel = BSP_OPTO_CH_IN2, + .expected_state = BSP_OPTO_STATE_INACTIVE, + }, + { + .p_confirm_id = "opto_rs_active", + .p_prompt = "M5: RLY2 ON -> RS ACTIVE", + .channel = BSP_OPTO_CH_RS, + .expected_state = BSP_OPTO_STATE_ACTIVE, + }, + { + .p_confirm_id = "opto_rs_inactive", + .p_prompt = "M5: RLY2 OFF -> RS INACTIVE", + .channel = BSP_OPTO_CH_RS, + .expected_state = BSP_OPTO_STATE_INACTIVE, + }, +}; + +#define OPTO_STEP_COUNT ((size_t) (sizeof(K_STEPS) / sizeof(K_STEPS[0]))) + +/* ── Реализация тест-модуля ──────────────────────────────────────────────── */ + +/** + * @brief Инициализация: все три канала MODE_LEVEL, rs_as_gpio = true. + * + * Коллбэки не нужны — состояние читается через bsp_opto_read() синхронно + * после confirm. + */ +static void opto_init(void) +{ + static const bsp_opto_config_t K_CFG = { + .callbacks = { NULL, NULL, NULL }, + .modes = { BSP_OPTO_MODE_LEVEL, BSP_OPTO_MODE_LEVEL, BSP_OPTO_MODE_LEVEL }, + .edges = { BSP_OPTO_EDGE_RISING, BSP_OPTO_EDGE_RISING, BSP_OPTO_EDGE_RISING }, + .rs_as_gpio = true, + .debounce_ms = OPTO_DEBOUNCE_MS, + }; + + (void) bsp_opto_init(&K_CFG); +} + +/** + * @brief Выполнение теста: 6 шагов confirm_request → settle → verify. + * + * Для каждого шага: + * 1. Отправить confirm_request — TUI командует M5 переключить реле. + * 2. Ждать confirm через test_runner_wait_confirm(). + * Возвращает false при таймауте или отказе оператора. + * 3. После confirm дать дебаунсу отработать (OPTO_SETTLE_MS), + * гоняя bsp_opto_process() + bsp_usb_cdc_poll(). + * 4. Прочитать bsp_opto_read() и сравнить с expected. + * + * @return test_result_t с полями status и detail. + */ +static test_result_t opto_run(void) +{ + test_result_t result = { + .status = TEST_STATUS_PASS, + .duration_ms = 0U, + .detail = "", + }; + + for (size_t i = 0U; i < OPTO_STEP_COUNT; i++) + { + const opto_step_t *p_step = &K_STEPS[i]; + + /* 1–2. Запросить действие M5 и ждать confirm */ + const confirm_params_t params = { + .id = p_step->p_confirm_id, + .prompt = p_step->p_prompt, + .timeout_ms = 0U, /* использовать PROTOCOL_CONFIRM_TIMEOUT_MS */ + }; + + bool confirmed = test_runner_wait_confirm(¶ms); + + if (!confirmed) + { + /* + * Таймаут или явный отказ — тест пропускается целиком. + * Одна из двух причин: "confirm timeout" или "operator declined". + * test_runner_wait_confirm() не разделяет их — используем "confirm timeout" + * как универсальный повод для SKIP. + */ + result.status = TEST_STATUS_SKIP; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "confirm timeout on %s", + p_step->p_confirm_id); + return result; + } + + /* 3. Settle: дать дебаунсу отработать после переключения реле */ + uint32_t settle_until = bsp_tick_get_ms() + OPTO_SETTLE_MS; + while (bsp_tick_get_ms() < settle_until) + { + bsp_opto_process(); + bsp_usb_cdc_poll(); + } + + /* 4. Верификация */ + bsp_opto_state_t got = bsp_opto_read(p_step->channel); + if (got != p_step->expected_state) + { + const char *p_exp_str = + (p_step->expected_state == BSP_OPTO_STATE_ACTIVE) ? "ACTIVE" : "INACTIVE"; + const char *p_got_str = (got == BSP_OPTO_STATE_ACTIVE) ? "ACTIVE" : "INACTIVE"; + + result.status = TEST_STATUS_FAIL; + (void) snprintf(result.detail, TEST_DETAIL_SIZE, "%s mismatch: expected %s got %s", + p_step->p_confirm_id, p_exp_str, p_got_str); + return result; + } + } + + return result; +} + +/* ── Дескриптор тест-модуля ──────────────────────────────────────────────── */ + +/** @brief Дескриптор для регистрации в test_runner. */ +const test_module_t K_TEST_OPTO = { + .id = "opto", + .name = "Opto Inputs", + .critical = false, + .requires_hil = true, + .pre_confirm_prompt = NULL, + .init = opto_init, + .run = opto_run, + .deinit = NULL, +}; \ No newline at end of file diff --git a/just/host.just b/just/host.just index 9218b9e..7b3de76 100644 --- a/just/host.just +++ b/just/host.just @@ -370,6 +370,21 @@ hil-usb-cdc: 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 + +[doc('Запустить HIL-тест CAN через firmware_test CDC (протокол v2)')] +[group('hil')] +hil-firmware-can: + 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 + # ============================================================================= # ГРУППА: debug — GDB-сервер для отладки из VSCode (devcontainer) # @@ -476,6 +491,7 @@ m5-deploy: echo " 📤 Копируем агент на M5Stack..." uv run --directory "{{ HIL_DIR }}" mpremote connect "{{ M5_PORT }}" \ cp "{{ _m5_agent }}" :main.py + uv run --directory "{{ HIL_DIR }}" mpremote connect "{{ M5_PORT }}" reset echo " ✅ m5_agent.py → /main.py" echo " ✅ M5Stack перезапущен" diff --git a/tools/hil/06_test_firmware_can.py b/tools/hil/06_test_firmware_can.py new file mode 100644 index 0000000..31489af --- /dev/null +++ b/tools/hil/06_test_firmware_can.py @@ -0,0 +1,128 @@ +""" +06_test_firmware_can.py — HIL тест test_can через firmware_test CDC протокол v2. + +Проверяет test_can в составе firmware_test: + Шаг 1 (RX): при confirm_request "can_rx_ready" M5 отправляет CAN фрейм, + затем оркестратор подтверждает confirmed:true. + Таргет принимает фрейм и верифицирует id+data. + Шаг 2 (TX): таргет отправляет фрейм, затем confirm_request "can_tx_verify". + Оркестратор принимает фрейм на M5, проверяет id+data, + отправляет confirmed:true/false. + +Запуск: + just host::hil-firmware-can + uv run --directory tools/hil pytest 06_test_firmware_can.py -v +""" + +import time + +import pytest +from conftest import FirmwareCdc, M5Agent + +# CAN параметры — должны совпадать с test_can.c +CAN_RX_ID = 0x100 +CAN_RX_DATA = [0xDE, 0xAD, 0xBE, 0xEF] +CAN_TX_ID = 0x200 +CAN_TX_DATA = [0xCA, 0xFE, 0xBA, 0xBE] + +# Таймаут для M5 can_recv — с запасом относительно CAN_RX_TIMEOUT_MS (500 мс) в прошивке +CAN_RECV_TIMEOUT_MS = 1000 + +@pytest.mark.usb_vcom +class TestFirmwareCan: + """Тест CAN-интерфейса через firmware_test CDC (протокол v2).""" + + @pytest.fixture(autouse=True) + def _setup(self, firmware_cdc: FirmwareCdc, m5: M5Agent) -> None: + self.cdc = firmware_cdc + self.m5 = m5 + + def _on_confirm(self, confirm_id: str) -> bool: + """ + Оркестратор CAN-теста. + + can_rx_ready: + M5 отправляет фрейм на шину ДО confirmed:true. + Таргет вызовет bsp_can_receive() после confirm. + + can_tx_verify: + Таргет уже отправил фрейм ДО confirm_request. + M5 принимает фрейм, проверяет id+data, возвращает результат. + """ + import logging + logging.getLogger(__name__).info("on_confirm called: %r", confirm_id) + + if confirm_id == "can_rx_ready": + self.m5.can_send(CAN_RX_ID, CAN_RX_DATA) + # Небольшая пауза чтобы фрейм успел уйти на шину + time.sleep(0.05) + return True + + if confirm_id == "can_tx_verify": + try: + frame = self.m5.can_recv(timeout_ms=CAN_RECV_TIMEOUT_MS) + import logging + logging.getLogger(__name__).info( + "M5 received frame: id=0x%X data=%s", + frame.get("id", -1), frame.get("data", []) + ) + except (TimeoutError, RuntimeError) as e: + import logging + logging.getLogger(__name__).warning("M5 can_recv failed: %s", e) + return False + + # Верифицировать id и data + if frame.get("id") != CAN_TX_ID: + return False + if frame.get("data") != CAN_TX_DATA: + return False + return True + + return False + + def test_ping(self) -> None: + """Базовая проверка CDC-канала.""" + self.cdc.ping() + + def test_can_pass(self) -> None: + """ + Полный прогон test_can: RX + TX через M5. + Ожидаем status=pass. + """ + result = self.cdc.run_hil_test( + test_id="can", + on_confirm=self._on_confirm, + timeout_s=60.0, + ) + + assert result.get("status") == "pass", ( + f"test_can вернул {result.get('status')!r}: " + f"{result.get('detail', '')}" + ) + + def test_can_rx_fail_no_frame(self) -> None: + """ + Негативный: при can_rx_ready не отправляем фрейм, но подтверждаем. + Таргет вызовет bsp_can_receive() и получит timeout → status=fail. + """ + + def bad_confirm(confirm_id: str) -> bool: + # Подтверждаем без отправки фрейма + if confirm_id == "can_rx_ready": + return True + # can_tx_verify не достигается — тест упадёт раньше + return False + + result = self.cdc.run_hil_test( + test_id="can", + on_confirm=bad_confirm, + timeout_s=30.0, + ) + + assert result.get("status") == "fail", ( + f"Ожидали fail, получили {result.get('status')!r}" + ) + detail = result.get("detail", "") + assert "no frame" in detail, ( + f"detail должен содержать 'no frame': {detail!r}" + ) \ No newline at end of file diff --git a/tools/hil/06_test_firmware_opto.py b/tools/hil/06_test_firmware_opto.py new file mode 100644 index 0000000..cf96014 --- /dev/null +++ b/tools/hil/06_test_firmware_opto.py @@ -0,0 +1,108 @@ +""" +06_test_firmware_opto.py — HIL тест test_opto через firmware_test CDC протокол v2. + +Проверяет test_opto в составе firmware_test: + - 6 шагов: IN1 active/inactive, IN2 active/inactive, RS active/inactive + - Оркестратор: при confirm_request с известным id командует M5 переключить реле, + затем отправляет confirmed:true + +Маппинг confirm_id → действие M5: + opto_in1_active → opto_set(1, True) (RLY3 ON) + opto_in1_inactive → opto_set(1, False) (RLY3 OFF) + opto_in2_active → opto_set(2, True) (RLY4 ON) + opto_in2_inactive → opto_set(2, False) (RLY4 OFF) + opto_rs_active → opto_set(3, True) (RLY2 ON) + opto_rs_inactive → opto_set(3, False) (RLY2 OFF) + +Запуск: + just host::hil-firmware-opto + uv run --directory tools/hil pytest 06_test_firmware_opto.py -v +""" + +import time + +import pytest +from conftest import FirmwareCdc, M5Agent + + +RELAY_ON_S = 0.15 # реле замыкается быстро +RELAY_OFF_S = 0.5 # размыкание + дебаунс прошивки с запасом + + +# Маппинг confirm_id → (opto_ch, state) +_OPTO_ACTIONS: dict[str, tuple[int, bool]] = { + "opto_in1_active": (1, True), + "opto_in1_inactive": (1, False), + "opto_in2_active": (2, True), + "opto_in2_inactive": (2, False), + "opto_rs_active": (3, True), + "opto_rs_inactive": (3, False), +} + +@pytest.mark.usb_vcom +class TestFirmwareOpto: + """Тест оптовходов через firmware_test CDC (протокол v2).""" + + @pytest.fixture(autouse=True) + def _setup(self, firmware_cdc: FirmwareCdc, m5: M5Agent) -> None: + self.cdc = firmware_cdc + self.m5 = m5 + # Сбросить все реле перед тестом + self.m5.opto_all_off() + time.sleep(RELAY_ON_S) + + def _on_confirm(self, confirm_id: str) -> bool: + action = _OPTO_ACTIONS.get(confirm_id) + if action is None: + return False + + ch, state = action + self.m5.opto_set(ch, state) + time.sleep(RELAY_ON_S if state else RELAY_OFF_S) + return True + + def test_ping(self) -> None: + """Базовая проверка CDC-канала.""" + self.cdc.ping() + + def test_opto_pass(self) -> None: + """ + Запустить test_opto через firmware_test. + Оркестратор автоматически управляет M5 при каждом confirm_request. + Ожидаем status=pass. + """ + result = self.cdc.run_hil_test( + test_id="opto", + on_confirm=self._on_confirm, + timeout_s=60.0, + ) + + assert result.get("status") == "pass", ( + f"test_opto вернул {result.get('status')!r}: " + f"{result.get('detail', '')}" + ) + + def test_opto_in1_fail_on_inactive(self) -> None: + """ + Негативный тест: при opto_in1_active НЕ включаем реле. + Ожидаем status=fail с detail содержащим 'in1' и 'mismatch'. + """ + + def bad_confirm(confirm_id: str) -> bool: + # Не переключаем реле — канал остаётся INACTIVE + # Но подтверждаем чтобы тест продолжился + return True + + result = self.cdc.run_hil_test( + test_id="opto", + on_confirm=bad_confirm, + timeout_s=60.0, + ) + + assert result.get("status") == "fail", ( + f"Ожидали fail, получили {result.get('status')!r}" + ) + detail = result.get("detail", "") + assert "mismatch" in detail, ( + f"detail должен содержать 'mismatch': {detail!r}" + ) \ No newline at end of file diff --git a/tools/hil/conftest.py b/tools/hil/conftest.py index 190f928..15d5e77 100644 --- a/tools/hil/conftest.py +++ b/tools/hil/conftest.py @@ -347,4 +347,185 @@ def uart_cmd(ser: serial.Serial, cmd: str) -> str: resp = ser.readline() if not resp: raise TimeoutError(f"Нет ответа на команду '{cmd}'") - return resp.decode("ascii", errors="replace").strip() \ No newline at end of file + return resp.decode("ascii", errors="replace").strip() + + +# --------------------------------------------------------------------------- +# firmware_test CDC — фикстура для тестирования firmware_test через протокол v2 +# --------------------------------------------------------------------------- + +class FirmwareCdc: + """ + Драйвер firmware_test протокола v2 (JSON-lines через USB CDC). + + Открывает CDC-порт, ждёт session_start, предоставляет методы: + - run_test(test_id) → запустить тест, дождаться test_result + - send_confirm(confirm_id, confirmed) → ответить на confirm_request + - wait_event(type, timeout_s) → ждать событие нужного типа + """ + + def __init__(self, ser: serial.Serial) -> None: + self._ser = ser + + def _readline(self, timeout_s: float = 5.0) -> dict: + """Читать строки до получения валидного JSON. TimeoutError если истёк таймаут.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + raw = self._ser.readline() + if not raw: + continue + text = raw.decode("ascii", errors="replace").strip() + if not text: + continue + try: + return json.loads(text) + except json.JSONDecodeError: + log.debug("firmware_cdc: не JSON: %r", text) + continue + raise TimeoutError(f"firmware_cdc: нет ответа за {timeout_s} с") + + def wait_event(self, event_type: str, timeout_s: float = 5.0) -> dict: + """ + Ждать событие с заданным полем type. + Пропускает промежуточные события (progress и т.п.). + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + msg = self._readline(timeout_s=max(remaining, 0.1)) + if msg.get("type") == event_type: + return msg + log.debug("firmware_cdc: пропускаем %r (ждём %r)", msg.get("type"), event_type) + raise TimeoutError( + f"firmware_cdc: событие {event_type!r} не получено за {timeout_s} с" + ) + + def send(self, payload: dict) -> None: + """Отправить JSON-команду на таргет.""" + line = json.dumps(payload, separators=(",", ":")) + "\n" + self._ser.write(line.encode("ascii")) + self._ser.flush() + + def ping(self) -> None: + """Проверка связи: ping → pong.""" + self.send({"type": "cmd", "cmd": "ping"}) + self.wait_event("pong") + + def send_confirm(self, confirm_id: str, confirmed: bool) -> None: + """Ответить на confirm_request.""" + self.send({"type": "confirm", "id": confirm_id, "confirmed": confirmed}) + + def run_test(self, test_id: str, timeout_s: float = 60.0) -> dict: + """ + Запустить тест по id, вернуть dict test_result. + + Промежуточные confirm_request игнорируются — их нужно обрабатывать + отдельно через wait_event("confirm_request") до вызова run_test, + или через on_confirm callback в специализированных тестах. + + Для HIL-тестов (opto, can) используй run_hil_test(). + """ + self.send({"type": "cmd", "cmd": "run", "id": test_id}) + self.wait_event("test_begin", timeout_s=5.0) + return self.wait_event("test_result", timeout_s=timeout_s) + + def run_hil_test( + self, + test_id: str, + on_confirm, + timeout_s: float = 30.0, + ) -> dict: + """ + Запустить HIL-тест с обработкой confirm_request. + + on_confirm(confirm_id: str) → bool вызывается для каждого confirm_request. + Возвращает True → отправляет confirmed:true, False → confirmed:false. + + Цикл завершается при получении test_result. + + Args: + test_id: идентификатор теста в реестре таргета + on_confirm: callable(confirm_id: str) → bool + timeout_s: общий таймаут на весь тест + """ + self.send({"type": "cmd", "cmd": "run", "id": test_id}) + self.wait_event("test_begin", timeout_s=5.0) + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + msg = self._readline(timeout_s=max(remaining, 0.1)) + msg_type = msg.get("type") + + if msg_type == "test_result": + return msg + + if msg_type == "confirm_request": + confirm_id = msg.get("id", "") + log.info("firmware_cdc: confirm_request %r", confirm_id) + confirmed = on_confirm(confirm_id) + self.send_confirm(confirm_id, confirmed) + continue + + log.debug("firmware_cdc: промежуточное событие %r", msg_type) + + raise TimeoutError( + f"firmware_cdc: test_result для {test_id!r} не получен за {timeout_s} с" + ) + + +@pytest.fixture(scope="module") +def firmware_cdc(m5: M5Agent) -> Generator[FirmwareCdc, None, None]: + """ + Открыть USB CDC порт firmware_test, дождаться session_start. + + Зависит от m5 — питание таргета уже включено. + firmware_test должна быть прошита в Flash (via SDP) и запущена. + + Порт берётся из TARGET_VCOM_PORT (HIL_USB_CDC_PORT в .env). + """ + if not cfg.TARGET_VCOM_PORT: + pytest.skip("HIL_USB_CDC_PORT не задан — пропускаем firmware_cdc тесты") + + # Ждём появления CDC порта после включения питания + deadline = time.monotonic() + cfg.TARGET_VCOM_TIMEOUT + ser = None + while time.monotonic() < deadline: + try: + ser = serial.Serial( + port=cfg.TARGET_VCOM_PORT, + baudrate=cfg.TARGET_VCOM_BAUD, + timeout=1.0, + write_timeout=1.0, + ) + break + except serial.SerialException: + time.sleep(0.2) + + if ser is None: + pytest.fail( + f"CDC порт {cfg.TARGET_VCOM_PORT} недоступен " + f"за {cfg.TARGET_VCOM_TIMEOUT} с" + ) + + try: + ser.dtr = True + time.sleep(0.3) + ser.reset_input_buffer() + + cdc = FirmwareCdc(ser) + + # session_start — одноразовое событие при старте, может быть уже пропущено. + # Проверяем живость через ping → pong. + try: + cdc.ping() + log.info("firmware_test: ping OK, прошивка активна") + except (TimeoutError, Exception) as exc: + pytest.fail( + f"firmware_test не отвечает на ping — " + f"прошивка запущена? Порт верный? ({exc})" + ) + + yield cdc + finally: + ser.close() \ No newline at end of file