# firmware_test: can & opto

This commit is contained in:
Dmitry Akimov 2026-06-24 16:22:36 +03:00
parent 293b61fff3
commit 20bebf6d5e
15 changed files with 1518 additions and 168 deletions

View file

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

View file

@ -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` таргета.
---

View file

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

View file

@ -1,18 +1,19 @@
# firmware_test — План разработки
> Версия: 0.5 | Обновлён после завершения Этапа 5 (display + buttons).
> Версия: 0.6 | Обновлён после завершения Этапа 5 (display + buttons) и архитектурных решений по Этапам 68.
---
## Контекст проекта
**Цель прошивки:** входной контроль платы 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 @@
> Не пересматривать без явного запроса.
### Этапы 15 (ранее зафиксированные)
- **Транспорт:** 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 тест:** захардкоженная мелодия 35 с, `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 = "<id> 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 = "<id> 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:**
- Захардкоженная мелодия, 35 секунд
- `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 <bin> # прошить без 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?
```bash
6а (протокол) → 6б (opto) → 6в (can) → 6г (mqs)
6д (conftest) → 6е (opto pytest) → 6ж (can pytest)
7 (provisioning)
8 (TUI)
```

View file

@ -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");
}

View file

@ -11,7 +11,6 @@
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
/* ── Константы ─────────────────────────────────────────────────────────── */
/**
@ -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");

View file

@ -21,8 +21,8 @@
#include "test_module.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/** @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.
*

View file

@ -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);
}

View file

@ -17,6 +17,7 @@
#include "test_module.h"
#include <stdbool.h>
#include <stddef.h>
/**
* @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_ */

View file

@ -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 <stdint.h>
#include <stdio.h>
#include <string.h>
/* ── Константы ───────────────────────────────────────────────────────────── */
/** Битрейт 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,
};

View file

@ -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 <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ── Константы ───────────────────────────────────────────────────────────── */
/** Период дебаунса оптовходов, мс. */
#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];
/* 12. Запросить действие 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(&params);
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,
};

View file

@ -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 перезапущен"

View file

@ -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}"
)

View file

@ -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}"
)

View file

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