# 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
# 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_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 для ручного тестирования агента |
| `power.py` | Скрипт управления питанием таргета (RLY1) из командной строки |
@ -109,7 +109,6 @@ just host::m5-deploy
```
> ⚠️ После изменения `agent.py` обязательно задеплоить перед запуском тестов.
> Иначе на M5 работает старая версия.
### MCU-Link — загрузка ELF и UART CLI

View file

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

View file

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

View file

@ -26,7 +26,8 @@ just host::setup-tools
```bash
HIL_VCOM_PORT=/dev/cu.usbmodemXXX # MCU-Link VCOM
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 для ручной отправки команд агенту
```
Также можно запустить агент с интерактивным режимом CLI на хосте с помощью команды:
```bash
just host::m5-start
```
---
## Шаг 2 — Физическое подключение стенда
@ -82,8 +89,7 @@ just host::m5-power off
## Шаг 3 — Сборка HIL-прошивок (в devcontainer)
HIL-прошивки компилируются под ARM и собираются **внутри devcontainer**,
потому что там есть `arm-none-eabi-gcc` и весь SDK.
HIL-прошивки компилируются под ARM и собираются **внутри devcontainer**.
```bash
# Открыть проект в 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 — Запуск тестов (на хосте, вне контейнера)
@ -116,7 +119,8 @@ ls build/target-debug/tests/target/
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 перезагружается между файлами.
### Интерактивные HIL-тесты (требуют оператора)