# Refactoring: HIL docs

This commit is contained in:
Dmitry Akimov 2026-04-06 23:51:19 +03:00
parent ab6d2f1393
commit 6f0e082849
5 changed files with 130 additions and 79 deletions

View file

@ -108,14 +108,24 @@ PYOCD_FREQUENCY=4000000
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
# HIL — аппаратный стенд # HIL — аппаратный стенд
HIL_VCOM_PORT=/dev/cu.usbmodemXXXX # MCU-Link VCOM (macOS: cu.usbmodem*, Linux: ttyACM*)
HIL_M5_PORT=/dev/cu.usbmodemYYYY # M5StampPLC USB CDC
HIL_VCOM_BAUD=115200
HIL_M5_BAUD=115200
HIL_READY_TIMEOUT=5.0
HIL_M5_TIMEOUT=3.0
HIL_PYOCD_FREQUENCY=1000000 HIL_PYOCD_FREQUENCY=1000000
HIL_BUILD_DIR=build/target-debug HIL_BUILD_DIR=build/target-debug
HIL_TARGET_POWER_SETTLE_S=1.5 # задержка после включения питания (POR + стабилизация)
# VCOM на программаторе NXP MCU-Link
HIL_VCOM_PORT=/dev/cu.usbmodemXXXX # macOS: cu.usbmodem*, Linux: ttyACM*
HIL_VCOM_BAUD=115200
HIL_READY_TIMEOUT=5.0
# M5Stack StamPLC (промежуточная платформа для HIL)
HIL_M5_PORT=/dev/cu.usbmodemYYYY # M5StampPLC USB CDC
HIL_M5_BAUD=115200
HIL_M5_TIMEOUT=3.0
# VCOM на плате таргета (USB CDC, появляется после загрузки ELF)
HIL_USB_CDC_PORT=/dev/cu.usbmodemZZZZ
HIL_USB_CDC_BAUD=115200
HIL_USB_CDC_TIMEOUT=5.0
``` ```
**Как значения попадают в инструменты:** **Как значения попадают в инструменты:**

View file

@ -75,7 +75,7 @@ _OPTO_TO_RELAY = {1: 3, 2: 4, 3: 2}
| Файл | Назначение | | Файл | Назначение |
|--------------|----------------------------------------------------------------| |--------------|----------------------------------------------------------------|
| `agent.py` | MicroPython агент на M5. Принимает JSON-команды через USB CDC, управляет реле через I2C экспандер AW9523B | | `agent.py` | MicroPython агент на M5. Принимает JSON-команды через USB CDC, подает сигналы на таргет |
| `cli.py` | Интерактивный CLI для ручного тестирования агента | | `cli.py` | Интерактивный CLI для ручного тестирования агента |
| `power.py` | Скрипт управления питанием таргета (RLY1) из командной строки | | `power.py` | Скрипт управления питанием таргета (RLY1) из командной строки |
@ -109,7 +109,6 @@ just host::m5-deploy
``` ```
> ⚠️ После изменения `agent.py` обязательно задеплоить перед запуском тестов. > ⚠️ После изменения `agent.py` обязательно задеплоить перед запуском тестов.
> Иначе на M5 работает старая версия.
### MCU-Link — загрузка ELF и UART CLI ### MCU-Link — загрузка ELF и UART CLI

View file

@ -167,55 +167,59 @@ ls build/target-debug/tests/target/<name>/test_<name>.elf
## Шаг 5 — `conftest.py`: добавить фикстуры ## Шаг 5 — `conftest.py`: добавить фикстуры
Открыть `tools/hil/conftest.py` и добавить в конец раздела с фикстурами загрузки. Открыть `tools/hil/conftest.py` и добавить:
1. Фикстуру загрузки `loaded_<n>` в конец раздела загрузок.
2. Одну строку в `_UART_FIXTURE_MAP` — фабрика `_make_uart_fixture` автоматически
создаст фикстуру `uart_<n>` через контекстный менеджер `_uart_context`.
### Базовый тест (без M5) ### Базовый тест (без M5)
```python ```python
# 1. Фикстура загрузки — добавить в раздел loaded_*
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def loaded_<name>(request: pytest.FixtureRequest) -> None: def loaded_<n>(request: pytest.FixtureRequest) -> None:
_load_elf( _load_elf(
request, request,
Path(cfg.BUILD_DIR) / "tests/target/<name>/test_<name>.elf", Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf",
) )
@pytest.fixture(scope="module") # 2. UART-фикстура — добавить одну строку в словарь
def uart_<name>( _UART_FIXTURE_MAP = {
request: pytest.FixtureRequest, ...
loaded_<name>, # ← гарантирует порядок: ELF раньше UART "uart_<n>": "loaded_<n>", # ← добавить
) -> Generator[serial.Serial, None, None]: }
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
``` ```
### Тест с M5 (GPIO, реле, питание) ### Тест с M5 (GPIO, реле, питание)
```python ```python
# 1. Фикстура загрузки — зависимость от m5 гарантирует питание
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def loaded_<name>(request: pytest.FixtureRequest, m5: M5Agent) -> None: def loaded_<n>(request: pytest.FixtureRequest, m5: M5Agent) -> None:
""" """
Зависит от m5 — питание таргета уже включено к моменту загрузки ELF. Зависит от m5 — питание таргета уже включено к моменту загрузки ELF.
""" """
_load_elf( _load_elf(
request, request,
Path(cfg.BUILD_DIR) / "tests/target/<name>/test_<name>.elf", Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf",
) )
@pytest.fixture(scope="module") # 2. UART-фикстура — та же одна строка
def uart_<name>( _UART_FIXTURE_MAP = {
request: pytest.FixtureRequest, ...
loaded_<name>, "uart_<n>": "loaded_<n>", # ← добавить
) -> Generator[serial.Serial, None, None]: }
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
``` ```
**Правило:** если тест управляет железом через M5 — `loaded_<name>` должен явно **Правило:** если тест управляет железом через M5 — `loaded_<n>` должен явно
зависеть от `m5`. Это гарантирует что питание включено до того как pyOCD зависеть от `m5`. Это гарантирует что питание включено до того как pyOCD
попытается подключиться к MCU. попытается подключиться к MCU.
> Ручное написание `uart_<n>` фикстур больше не требуется — фабрика
> `_make_uart_fixture` создаёт фикстуру с `_uart_context` (контекстный менеджер,
> гарантирует `ser.close()` при любом исходе).
--- ---
## Шаг 6 — `tools/hil/test_<name>.py` ## Шаг 6 — `tools/hil/test_<name>.py`
@ -429,7 +433,7 @@ uart.close() test_something
[ ] tests/target/<n>/CMakeLists.txt — сборка с bsp_boot_ram [ ] tests/target/<n>/CMakeLists.txt — сборка с bsp_boot_ram
[ ] tests/target/CMakeLists.txt — add_subdirectory(<n>) [ ] tests/target/CMakeLists.txt — add_subdirectory(<n>)
[ ] CMakePresets.json — добавить test_<n> в targets [ ] CMakePresets.json — добавить test_<n> в targets
[ ] tools/hil/conftest.py — loaded_<n> + uart_<n> [ ] tools/hil/conftest.py — loaded_<n> + строка в _UART_FIXTURE_MAP
[ ] tools/hil/test_<n>.py — pytest-тесты [ ] tools/hil/test_<n>.py — pytest-тесты
[ ] just/host.just — рецепт hil-<n> (опционально) [ ] just/host.just — рецепт hil-<n> (опционально)
[ ] just build::build-hil — зелёная сборка [ ] just build::build-hil — зелёная сборка

View file

@ -138,14 +138,14 @@ def flush_uart_buffer(uart):
``` ```
**Правило:** `autouse` в `conftest.py` действует на **все** тесты в директории **Правило:** `autouse` в `conftest.py` действует на **все** тесты в директории
и поддиректориях. Используй осторожно — можно случайно затронуть тесты, и поддиректориях. Используется осторожно — можно случайно затронуть тесты,
которым эта фикстура не нужна. которым эта фикстура не нужна.
--- ---
## 5. Зависимости между фикстурами ## 5. Зависимости между фикстурами
Зависимость объявляется **в сигнатуре** фикстуры. pytest строит DAG и гарантирует Зависимость объявляется **в сигнатуре** фикстуры. pytest строит `DAG` и гарантирует
порядок создания. порядок создания.
```python ```python
@ -200,15 +200,18 @@ tools/hil/
└── conftest.py ← (если бы был) виден только в m5/ └── conftest.py ← (если бы был) виден только в m5/
``` ```
### Что живёт в `conftest.py` нашего проекта ### Что живёт в `conftest.py` проекта
| Фикстура / функция | Scope | Назначение | | Фикстура / функция | Scope | Назначение |
|---------------------|-------|------------| |---------------------|-------|------------|
| `_load_elf()` | вспомогательная | pyOCD: halt → FLEXRAM → load ELF → run | | `_load_elf()` | вспомогательная | pyOCD: halt → FLEXRAM → load ELF → run |
| `_open_uart_and_wait_ready()` | вспомогательная | Открыть VCOM, дождаться `READY\r\n` | | `_uart_context()` | контекстный менеджер | Открыть VCOM, дождаться `READY\r\n`, гарантировать `close()` |
| `_make_uart_fixture()` | фабрика | Генерирует `uart_*` фикстуры из `_UART_FIXTURE_MAP` |
| `_UART_FIXTURE_MAP` | словарь | Связь `uart_<n>``loaded_<n>` для всех тестов |
| `loaded_host_uart` | module | Загрузить `test_host_uart.elf` | | `loaded_host_uart` | module | Загрузить `test_host_uart.elf` |
| `loaded_hil_opto` | module | Загрузить `test_hil_opto.elf`, зависит от `m5` | | `loaded_hil_opto` | module | Загрузить `test_hil_opto.elf`, зависит от `m5` |
| `uart` / `uart_hil_opto` | module | Открыть UART CLI, зависит от `loaded_*` | | `uart` / `uart_opto` / ... | module | Создаются автоматически через `_UART_FIXTURE_MAP` |
| `usb_cdc_port` | module | USB CDC порт таргета, использует `cfg.TARGET_VCOM_*` |
| `m5` | module | Подключиться к M5, включить питание | | `m5` | module | Подключиться к M5, включить питание |
| `uart_cmd()` | обычная функция | Отправить команду, прочитать ответ | | `uart_cmd()` | обычная функция | Отправить команду, прочитать ответ |
@ -276,17 +279,37 @@ def loaded_<name>(request, m5): # m5 — только если нужно п
Teardown не нужен — MCU будет перезагружен при следующей загрузке ELF. Teardown не нужен — MCU будет перезагружен при следующей загрузке ELF.
### 9.2. UART-сессия (с teardown) ### 9.2. UART-сессия (через фабрику)
Для создания UART-фикстур используется фабрика
`_make_uart_fixture` и словарь `_UART_FIXTURE_MAP`. Иначе, пришлось бы
вручную писать фикстуры`uart` / `uart_opto` / ... для каждого таргета.
```python ```python
_UART_FIXTURE_MAP = {
"uart": "loaded_host_uart",
"uart_opto": "loaded_hil_opto",
"uart_can": "loaded_hil_can",
"uart_button": "loaded_hil_button",
"uart_hil_usb_cdc": "loaded_hil_usb_cdc",
}
def _make_uart_fixture(loaded_name: str):
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def uart_<name>(request, loaded_<name>): def _fixture(request: pytest.FixtureRequest) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request) request.getfixturevalue(loaded_name)
port = request.config.getoption("--vcom")
with _uart_context(port, cfg.VCOM_BAUD, cfg.READY_TIMEOUT) as ser:
yield ser yield ser
ser.close() return _fixture
for _name, _dep in _UART_FIXTURE_MAP.items():
globals()[_name] = _make_uart_fixture(_dep)
``` ```
`yield` обязателен — порт нужно закрыть, иначе следующий файл не сможет его открыть. Для нового теста достаточно одной строки в `_UART_FIXTURE_MAP`.
Контекстный менеджер `_uart_context` гарантирует `ser.close()` при любом исходе
(исключение, `pytest.fail`, `KeyboardInterrupt`).
### 9.3. Управление стендом (M5 — с teardown) ### 9.3. Управление стендом (M5 — с teardown)
@ -352,21 +375,33 @@ def test_opto_ch1(self):
### 9.6. Ожидание `READY` от прошивки ### 9.6. Ожидание `READY` от прошивки
Паттерн из проекта — прошивка шлёт `READY\r\n` в цикле, пока хост не откроет порт: Паттерн из проекта — прошивка шлёт `READY\r\n` в цикле, пока хост не откроет порт.
Реализован как контекстный менеджер `_uart_context`, который гарантирует закрытие
порта при любом исходе:
```python ```python
def _open_uart_and_wait_ready(request, timeout_s=5.0): @contextmanager
port = request.config.getoption("--vcom") def _uart_context(port: str, baud: int, ready_timeout: float):
ser = serial.Serial(port=port, baudrate=115200, timeout=2.0) ser = serial.Serial(port=port, baudrate=baud, timeout=2.0, write_timeout=1.0)
deadline = time.monotonic() + timeout_s try:
deadline = time.monotonic() + ready_timeout
ready = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
line = ser.readline().decode(errors="replace").strip() line = ser.readline().decode("ascii", errors="replace").strip()
if line == "READY": if line == "READY":
return ser ready = True
ser.close() break
raise TimeoutError("Прошивка не отправила READY") if not ready:
pytest.fail(f"Прошивка не отправила READY за {ready_timeout} с")
ser.reset_input_buffer()
yield ser
finally:
ser.close() # выполняется всегда
``` ```
Все `uart_*` фикстуры используют `_uart_context` через фабрику `_make_uart_fixture`
(см. секцию 9.2).
### 9.7. Параметризация фикстур ### 9.7. Параметризация фикстур
Один тест — несколько входных наборов. Полезно для проверки всех каналов: Один тест — несколько входных наборов. Полезно для проверки всех каналов:
@ -402,12 +437,10 @@ def uart(request, loaded_host_uart):
ser = _open_uart_and_wait_ready(request) ser = _open_uart_and_wait_ready(request)
return ser # ser.close() никогда не вызовется! return ser # ser.close() никогда не вызовется!
# ✅ yield + close # ✅ Актуальное решение: _uart_context (контекстный менеджер)
@pytest.fixture(scope="module") # ser.close() гарантирован блоком finally внутри _uart_context.
def uart(request, loaded_host_uart): # Все uart_* фикстуры создаются через _make_uart_fixture,
ser = _open_uart_and_wait_ready(request) # который использует _uart_context — ручной close() не нужен.
yield ser
ser.close()
``` ```
### Нет зависимости от `loaded_*` — UART открывается раньше ELF ### Нет зависимости от `loaded_*` — UART открывается раньше ELF
@ -478,21 +511,22 @@ def uart(fresh_state): # ScopeMismatch!
```python ```python
# 1. В conftest.py — загрузка ELF # 1. В conftest.py — загрузка ELF
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def loaded_<name>(request, m5): # m5 только если нужен def loaded_<n>(request, m5): # m5 только если нужен
_load_elf(request, Path(cfg.BUILD_DIR) / "tests/target/<name>/test_<name>.elf") _load_elf(request, Path(cfg.BUILD_DIR) / "tests/target/<n>/test_<n>.elf")
# 2. В conftest.py — UART-сессия # 2. В conftest.py — одна строка в _UART_FIXTURE_MAP
@pytest.fixture(scope="module") _UART_FIXTURE_MAP = {
def uart_<name>(request, loaded_<name>): ...
ser = _open_uart_and_wait_ready(request) "uart_<n>": "loaded_<n>", # ← добавить
yield ser }
ser.close() # Фабрика _make_uart_fixture создаст фикстуру автоматически
# с _uart_context (гарантирует ser.close())
# 3. В test_<name>.py — autouse setup # 3. В test_<n>.py — autouse setup
class Test<Name>: class Test<n>:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _setup(self, uart_<name>, m5): # m5 только если нужен def _setup(self, uart_<n>, m5): # m5 только если нужен
self.ser = uart_<name> self.ser = uart_<n>
self.m5 = m5 self.m5 = m5
def test_ping(self): def test_ping(self):

View file

@ -26,7 +26,8 @@ just host::setup-tools
```bash ```bash
HIL_VCOM_PORT=/dev/cu.usbmodemXXX # MCU-Link VCOM HIL_VCOM_PORT=/dev/cu.usbmodemXXX # MCU-Link VCOM
HIL_M5_PORT=/dev/cu.usbmodemYYY # M5StampPLC HIL_M5_PORT=/dev/cu.usbmodemYYY # M5StampPLC
HIL_BUILD_DIR=build/target-debug # путь к собранным ELF (обычно не менять) HIL_USB_CDC_PORT=/dev/cu.usbmodemZZZ # USB CDC порт на плате таргета (если тест использует USB CDC)
HIL_BUILD_DIR=build/target-debug # путь к собранным ELF (по умолчанию)
``` ```
Как найти нужные порты: Как найти нужные порты:
@ -55,6 +56,12 @@ just host::m5-deploy
just host::m5-cli # интерактивный CLI для ручной отправки команд агенту just host::m5-cli # интерактивный CLI для ручной отправки команд агенту
``` ```
Также можно запустить агент с интерактивным режимом CLI на хосте с помощью команды:
```bash
just host::m5-start
```
--- ---
## Шаг 2 — Физическое подключение стенда ## Шаг 2 — Физическое подключение стенда
@ -82,8 +89,7 @@ just host::m5-power off
## Шаг 3 — Сборка HIL-прошивок (в devcontainer) ## Шаг 3 — Сборка HIL-прошивок (в devcontainer)
HIL-прошивки компилируются под ARM и собираются **внутри devcontainer**, HIL-прошивки компилируются под ARM и собираются **внутри devcontainer**.
потому что там есть `arm-none-eabi-gcc` и весь SDK.
```bash ```bash
# Открыть проект в VSCode → Reopen in Container # Открыть проект в VSCode → Reopen in Container
@ -103,9 +109,6 @@ ls build/target-debug/tests/target/
# ... # ...
``` ```
> Пересобирать нужно только при изменении C-кода прошивок (`tests/target/*/main.c`
> или BSP). Изменения в Python-тестах (`tools/hil/test_*.py`) сборки не требуют.
--- ---
## Шаг 4 — Запуск тестов (на хосте, вне контейнера) ## Шаг 4 — Запуск тестов (на хосте, вне контейнера)
@ -116,7 +119,8 @@ ls build/target-debug/tests/target/
just host::hil-run just host::hil-run
``` ```
pytest обходит все `test_*.py` в `tools/hil/`, **исключая** помеченные `@pytest.mark.interactive`. pytest обходит все `test_*.py` в `tools/hil/`, **исключая** помеченные `@pytest.mark.interactive` и
`@pytest.mark.usb_vcom`.
Каждый файл — своя загрузка ELF, свой UART-сеанс, MCU перезагружается между файлами. Каждый файл — своя загрузка ELF, свой UART-сеанс, MCU перезагружается между файлами.
### Интерактивные HIL-тесты (требуют оператора) ### Интерактивные HIL-тесты (требуют оператора)