diff --git a/.github/scripts/bootstrap-uv.sh b/.github/scripts/bootstrap-uv.sh new file mode 100755 index 00000000..7095a73c --- /dev/null +++ b/.github/scripts/bootstrap-uv.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Ensure `uv` is available in $HOME/.local/bin on the runner. +# +# Called at the start of every hw-test workflow step that needs uv. +# On a fresh runner this downloads the standalone installer (~15 MB); +# on subsequent runs it's a no-op. Exports PATH for the caller via +# $GITHUB_PATH so later steps in the same job see ~/.local/bin too. +# +# Mirror of pyadi-iio's .github/scripts/bootstrap-uv.sh — same labs, +# same convention. + +set -euo pipefail + +UV_BIN="$HOME/.local/bin/uv" + +if [[ ! -x "$UV_BIN" ]]; then + echo "uv not found at $UV_BIN — installing via astral.sh" >&2 + curl -LsSf https://astral.sh/uv/install.sh | sh +fi + +echo "$HOME/.local/bin" >> "$GITHUB_PATH" +"$UV_BIN" --version diff --git a/.github/scripts/install-trx-hw-venv.sh b/.github/scripts/install-trx-hw-venv.sh new file mode 100755 index 00000000..b50627f7 --- /dev/null +++ b/.github/scripts/install-trx-hw-venv.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Install TransceiverToolbox HW-CI Python deps into a persistent +# uv-managed venv at ~/.cache/trx-hw-ci/venv on the current runner. +# +# Mirror of pyadi-iio's install-pyadi-iio-venv.sh, but our deps are +# only what's needed for the labgrid boot lifecycle + the MATLAB +# bridge tests — no pyadi-iio, no matplotlib, no scipy: +# +# adi-labgrid-plugins[kuiper] registers labgrid resource/driver +# classes the env yamls reference; the +# pytest11 plugin auto-registers and +# drives HW_DAUGHTER / HW_CARRIER +# marker filtering. +# pytest, pyyaml minimal test runner deps. + +set -euo pipefail + +VENV="$HOME/.cache/trx-hw-ci/venv" + +export PATH="$HOME/.local/bin:$PATH" + +if [[ ! -x "$VENV/bin/python" ]]; then + echo "Creating trx HW-CI venv at $VENV" >&2 + uv venv --quiet "$VENV" +fi + +uv pip install --quiet --python "$VENV/bin/python" \ + -r test/hw/requirements_dev.txt diff --git a/.github/workflows/hardware-test.yml b/.github/workflows/hardware-test.yml new file mode 100644 index 00000000..0630d120 --- /dev/null +++ b/.github/workflows/hardware-test.yml @@ -0,0 +1,232 @@ +name: Hardware Tests + +# Modelled on pyadi-iio's hardware-test.yml (same labs, same coordinator, +# same conventions). The shape is intentional: preflight probes the +# coordinator for places, intersects against `test/hw/env/*.yaml` +# committed in this repo, and emits a matrix; per-place shards acquire +# the place + run pytest + release. The bridge tests in test/hw/ +# subprocess MATLAB once per supported board reference name. + +permissions: + contents: read + # dorny/test-reporter creates Check Runs for the per-leg JUnit reports. + checks: write + pull-requests: write + +on: + push: + branches: [master] + pull_request: + # Gated below by the `hw-test` label so PRs don't auto-grab the + # self-hosted runners. + types: [labeled, opened, synchronize, reopened] + workflow_dispatch: + +env: + COORDINATOR: ${{ vars.ADI_LG_COORDINATOR }} + +concurrency: + group: hw-${{ github.ref }} + cancel-in-progress: false + +jobs: + preflight: + # Gate: on PRs only when the `hw-test` label is applied. Push/dispatch + # always run. + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'hw-test') + runs-on: [self-hosted, hw-coordinator] + timeout-minutes: 3 + outputs: + available_nodes: ${{ steps.probe.outputs.available_nodes }} + steps: + - uses: actions/checkout@v4 + + - name: Bootstrap uv + run: bash .github/scripts/bootstrap-uv.sh + + - id: probe + timeout-minutes: 2 + env: + LABGRID_PIP: 'labgrid @ git+https://github.com/tfcollins/labgrid.git@tfcollins/plugin-support' + run: | + set -euo pipefail + HOST="${COORDINATOR%%:*}" + PORT="${COORDINATOR##*:}" + if ! timeout 10 bash -c "/dev/null; then + echo "Coordinator $COORDINATOR unreachable — marking all nodes unavailable." >&2 + echo 'available_nodes=[]' >> "$GITHUB_OUTPUT" + exit 0 + fi + export PATH="$HOME/.local/bin:$PATH" + VENV="$HOME/.cache/trx-hw-ci/labgrid-venv" + if [[ ! -x "$VENV/bin/labgrid-client" ]]; then + echo "Creating labgrid venv at $VENV" >&2 + uv venv --quiet "$VENV" + uv pip install --quiet --python "$VENV/bin/python" "$LABGRID_PIP" + fi + LGCLIENT="$VENV/bin/labgrid-client" + echo "Using labgrid-client: $LGCLIENT" >&2 + echo "--- places -v output ---" >&2 + PLACES_OUT=$(timeout 30 "$LGCLIENT" -x "$COORDINATOR" -v places 2>&1 || true) + echo "$PLACES_OUT" >&2 + echo "--- end places -v output ---" >&2 + python3 - "$PLACES_OUT" >> "$GITHUB_OUTPUT" <<'PY' + import json, os, re, sys + # Discovery-driven matrix (same pattern pyadi-iio uses): + # 1. Coordinator publishes places, each tagged with + # carrier= / daughter-board=. + # 2. Per-place env yamls live at test/hw/env/.yaml. + # A place is auto-included when its yaml exists in the + # repo. + # 3. We also emit the place's `daughter-board` tag so the + # shard can set $HW_DAUGHTER for the adi_lg_plugins + # pytest plugin's iio_hardware-marker filter. + place_re = re.compile(r"^Place '(?P[^']+)':") + tags_re = re.compile(r"^\s+tags:\s+(?P.*)$") + places = {} + current = None + for line in sys.argv[1].splitlines(): + m = place_re.match(line) + if m: + current = m.group("name") + places[current] = {} + continue + if current is None: + continue + m = tags_re.match(line) + if m: + for kv in m.group("tags").split(","): + kv = kv.strip() + if "=" in kv: + k, v = kv.split("=", 1) + places[current][k.strip()] = v.strip() + + repo_root = os.environ.get("GITHUB_WORKSPACE", ".") + avail = [] + for name, tags in sorted(places.items()): + env_yaml = f"test/hw/env/{name}.yaml" + if os.path.exists(os.path.join(repo_root, env_yaml)): + avail.append({ + "place": name, + "env_remote": env_yaml, + "daughter_board": tags.get("daughter-board", ""), + "carrier": tags.get("carrier", ""), + }) + else: + print( + f"skip: place {name!r} (tags={tags}) — {env_yaml} not in repo", + file=sys.stderr, + ) + print("available_nodes=" + json.dumps(avail)) + PY + + hw-coord: + needs: preflight + if: needs.preflight.outputs.available_nodes != '[]' + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.preflight.outputs.available_nodes) }} + name: hw-coord (${{ matrix.node.place }}) + # Per-place runner. `hw-` is the convention from + # adi-labgrid-plugins; the host running the runner must have MATLAB, + # ssh access to lab hosts as needed, and reach the coordinator. + runs-on: [self-hosted, "hw-${{ matrix.node.place }}"] + timeout-minutes: 40 + concurrency: + group: hw-coord-${{ matrix.node.place }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + # +adi/+common is a git submodule (ToolboxCommon, shared + # across ADI MATLAB toolboxes); MATLAB can't resolve + # adi.common.RxTx without it. + submodules: recursive + + - name: Bootstrap uv + run: bash .github/scripts/bootstrap-uv.sh + + - name: Install trx HW venv (adi-labgrid-plugins[kuiper] + pytest) + run: bash .github/scripts/install-trx-hw-venv.sh + + - name: Acquire coordinator place + env: + LG_COORDINATOR: ${{ env.COORDINATOR }} + run: | + set -euo pipefail + LGCLIENT="$HOME/.cache/trx-hw-ci/venv/bin/labgrid-client" + "$LGCLIENT" -x "$LG_COORDINATOR" -p "${{ matrix.node.place }}" acquire + + - name: Run coordinator-mode tests + env: + LG_COORDINATOR: ${{ env.COORDINATOR }} + LG_ENV: ${{ github.workspace }}/${{ matrix.node.env_remote }} + # HW_DAUGHTER drives adi_lg_plugins.pytest_plugin's + # iio_hardware-marker filter: tests whose + # @pytest.mark.iio_hardware([...]) doesn't include this chip + # are auto-skipped. + HW_DAUGHTER: ${{ matrix.node.daughter_board }} + HW_CARRIER: ${{ matrix.node.carrier }} + # MATLAB binary; override per-lab via vars.MATLAB_BIN. + MATLAB_BIN: ${{ vars.MATLAB_BIN || 'matlab' }} + run: | + set -euo pipefail + # The bridge tests in test/hw/test_*.py subprocess MATLAB. + # Bridge skips when iio_uri unresolved (conftest no-op); + # marker filter skips tests whose chip != $HW_DAUGHTER. + "$HOME/.cache/trx-hw-ci/venv/bin/pytest" \ + -v -s -p no:cacheprovider \ + test/hw/ \ + --junitxml=junit-hw-coord-${{ matrix.node.place }}.xml + + - name: Release coordinator place + if: always() + env: + LG_COORDINATOR: ${{ env.COORDINATOR }} + run: | + set -euo pipefail + LGCLIENT="$HOME/.cache/trx-hw-ci/venv/bin/labgrid-client" + "$LGCLIENT" -x "$LG_COORDINATOR" -p "${{ matrix.node.place }}" release || true + + - name: Publish hw-coord test results to PR + if: always() + uses: dorny/test-reporter@v1 + with: + name: Tests (hw-coord ${{ matrix.node.place }}) + path: junit-hw-coord-${{ matrix.node.place }}.xml + reporter: java-junit + fail-on-error: false + + - name: Upload JUnit + MATLAB artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: junit-hw-coord-${{ matrix.node.place }} + path: | + junit-hw-coord-${{ matrix.node.place }}.xml + ${{ matrix.node.daughter_board && format('*{0}*_HWTestResults.xml', matrix.node.daughter_board) || '*_HWTestResults.xml' }} + failures.txt + retention-days: 14 + if-no-files-found: ignore + + publish-pr-test-summary: + name: PR Test Summary + needs: [hw-coord] + if: always() && github.event_name == 'pull_request' && needs.hw-coord.result != 'skipped' + runs-on: [self-hosted, hw-coordinator] + steps: + - uses: actions/download-artifact@v4 + with: + pattern: junit-hw-* + path: junit + merge-multiple: true + + - uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: 'junit/**/*.xml' + check_name: 'Hardware Test Results' + comment_mode: always + report_individual_runs: true diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml deleted file mode 100644 index 98c290d5..00000000 --- a/.github/workflows/hw-matlab.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: Hardware Tests (labgrid) - -# Bespoke MATLAB hardware-CI workflow. labgrid (adi-labgrid-plugins) boots -# the board for a coordinator place, hands MATLAB the booted board's libIIO -# URI via the IIO_URI env var, and runs runHWTests against it. -# -# Two job stages: -# discover — on a coordinator-adjacent runner, intersect live coordinator -# places with test/hw_ci/board_map.yaml -> matrix of places. -# hw — one shard per place, pinned to its hw- self-hosted -# runner (which must have MATLAB + libiio installed), boots the -# board and runs MATLAB. -# -# Requires a coordinator reachable at vars.ADI_LG_COORDINATOR and self-hosted -# runners labeled [self-hosted, hw-coordinator] and [self-hosted, hw-] -# per the adi-labgrid-plugins HW-CI runner contract. - -on: - workflow_dispatch: - schedule: - - cron: "0 8 * * *" - pull_request: - types: [labeled, opened, synchronize, reopened] - -# A pinned reference to adi-labgrid-plugins. Bump alongside the coordinator. -env: - ADI_LG_PLUGINS_REF: "v2" - VENV_DIR: "${{ github.workspace }}/.hw-ci-venv" - # MATLAB binary path on the hw- runner. Override per-repo with - # vars.MATLAB_BIN — lab MATLAB installs vary (/opt vs /mnt vs /usr/local). - MATLAB_BIN: "${{ vars.MATLAB_BIN || '/opt/MATLAB/R2025b/bin/matlab' }}" - -# Minimal default token permissions; the publish job widens its own below. -permissions: - contents: read - -jobs: - discover: - # Only on PRs that opt in with the `hw-test` label; always on dispatch/cron. - if: >- - github.event_name != 'pull_request' || - contains(github.event.pull_request.labels.*.name, 'hw-test') - runs-on: [self-hosted, hw-coordinator] - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - count: ${{ steps.discover.outputs.count }} - steps: - - uses: actions/checkout@v4 - - - name: Setup venv (adi-labgrid-plugins) - uses: tfcollins/labgrid-plugins/.github/actions/setup-uv-venv@v2 - with: - venv_dir: ${{ env.VENV_DIR }} - install_cmd: >- - uv pip install --python "$VENV_DIR/bin/python" - "adi-labgrid-plugins @ git+https://github.com/tfcollins/labgrid-plugins.git@${{ env.ADI_LG_PLUGINS_REF }}" - - - name: Discover places - id: discover - env: - LG_COORDINATOR: ${{ vars.ADI_LG_COORDINATOR }} - run: | - "$VENV_DIR/bin/adi-lg-matlab" discover \ - --coord "$LG_COORDINATOR" \ - --board-map test/hw_ci/board_map.yaml \ - --github-output - - hw: - needs: discover - if: ${{ needs.discover.outputs.count != '0' }} - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.discover.outputs.matrix).include }} - runs-on: [self-hosted, "hw-${{ matrix.place }}"] - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - with: - # +adi/+common is a git submodule (ToolboxCommon shared across ADI - # MATLAB toolboxes). Without it MATLAB can't resolve adi.common.RxTx - # superclass and every test fails to instantiate. - submodules: recursive - - - name: Setup venv (adi-labgrid-plugins) - uses: tfcollins/labgrid-plugins/.github/actions/setup-uv-venv@v2 - with: - venv_dir: ${{ env.VENV_DIR }} - # [kuiper] extra pulls in pytsk3 — KuiperDLDriver needs it to - # extract uImage / devicetree.dtb from the Kuiper SD image. - install_cmd: >- - uv pip install --python "$VENV_DIR/bin/python" - "adi-labgrid-plugins[kuiper] @ git+https://github.com/tfcollins/labgrid-plugins.git@${{ env.ADI_LG_PLUGINS_REF }}" - - - name: Acquire place - uses: tfcollins/labgrid-plugins/.github/actions/acquire-place@v2 - with: - coordinator: ${{ vars.ADI_LG_COORDINATOR }} - place: ${{ matrix.place }} - labgrid_client: ${{ env.VENV_DIR }}/bin/labgrid-client - - - name: Boot board + run MATLAB HW tests - env: - LG_COORDINATOR: ${{ vars.ADI_LG_COORDINATOR }} - run: | - # Place is already acquired by the composite action above, so do - # NOT pass --acquire here (avoids double-acquire). - # - # --boot-strategy BootFPGASoCTFTP overrides the place's tag - # (currently BootZynq7000JTAGRecovery, which is a recovery - # strategy that doesn't load the IIO HDL). BootFPGASoCTFTP - # JTAG-bootstraps U-Boot, has KuiperDLDriver TFTP the kernel + - # devicetree from the cached Kuiper image, and boots Linux - # with the daughter-board's real HDL design active — so the - # toolbox's MATLAB tests can actually reach the IIO devices - # over libIIO instead of skipping via CheckDevice. - # - # runHWTests exit codes (see test/runHWTests.m): - # 0 = all passed - # 2 = one or more failed - # 3 = one or more Incomplete (e.g. CheckDevice assumeFail). - # On a real boot, 0 is the happy path; 2 propagates as failure. - # We still tolerate exit 3 (some boards may be unreachable for - # transient reasons; JUnit reflects skip status and publish - # aggregates). - set +e - "$VENV_DIR/bin/adi-lg-matlab" run \ - --coord "$LG_COORDINATOR" \ - --place "${{ matrix.place }}" \ - --board-map test/hw_ci/board_map.yaml \ - --repo-dir "$GITHUB_WORKSPACE" \ - --matlab "$MATLAB_BIN" \ - --boot-strategy BootFPGASoCTFTP \ - --junit "junit-${{ matrix.place }}.xml" - rc=$? - if [ "$rc" = "3" ]; then - echo "::notice::Tests reported Incomplete (CheckDevice assumeFail) — JUnit has details." - exit 0 - fi - exit "$rc" - - - name: Release place - if: always() - run: | - "$VENV_DIR/bin/labgrid-client" -x "${{ vars.ADI_LG_COORDINATOR }}" \ - -p "${{ matrix.place }}" release || true - - - name: Upload JUnit + MATLAB logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: hw-results-${{ matrix.place }} - path: | - junit-${{ matrix.place }}.xml - ${{ matrix.matlab_board }}_HWTestResults.xml - failures.txt - if-no-files-found: ignore - - publish: - needs: hw - # Run only when the hw matrix actually ran (i.e. the hw-test path was - # taken). When hw is skipped, there is nothing to publish — skip too, - # so this job never sits pending on a PR that has no HW runner. - if: ${{ always() && needs.hw.result != 'skipped' }} - runs-on: [self-hosted, hw-coordinator] - # The test-result action posts a check run and a PR comment. - permissions: - contents: read - checks: write - pull-requests: write - steps: - - uses: actions/download-artifact@v4 - with: - path: hw-results - - name: Publish test summary - uses: EnricoMi/publish-unit-test-result-action@v2 - with: - junit_files: "hw-results/**/junit-*.xml" diff --git a/.gitignore b/.gitignore index 8a7e8a54..26dce572 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,14 @@ **/slprj/** AD9361_Filter_Wizard/*TestFiltWiz*.m AD9361_Filter_Wizard/.previous_ip_addr -_generated/* \ No newline at end of file +_generated/* + +# runHWTests transient outputs (timestamped result mat + JUnit + failures +# log) — useful artifacts on the runner, never want them committed. +BSPTest_*.mat +*_HWTestResults.xml +failures.txt + +# pytest / Python caches from test/hw/ runs +__pycache__/ +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md index ceb2e289..d9a9f6d8 100644 --- a/README.md +++ b/README.md @@ -39,33 +39,51 @@ Then simply add the `hdl` folder to your MATLAB path `addpath(genpath('hdl'))` ## Hardware Testing with labgrid The MATLAB hardware tests (`runHWTests`) connect to a board over a libIIO URI -and honor the `IIO_URI` environment variable. They assume the board is already +and honour the `IIO_URI` environment variable. They assume the board is already powered, booted, and reachable. [adi-labgrid-plugins](https://github.com/analogdevicesinc/adi-labgrid-plugins) can provision that board automatically — power it, boot the FPGA/SoC, and hand -MATLAB the booted board's URI — both locally and in GitHub Actions, via the -`adi-lg-matlab` launcher. +MATLAB the booted URI — both locally and in GitHub Actions. The integration +mirrors the +[pyadi-iio hardware-CI pattern](https://github.com/analogdevicesinc/pyadi-iio): +a thin `test/hw/conftest.py` boots the board via labgrid and exposes an +`iio_uri` fixture; per-board bridge tests in `test/hw/test_*.py` carry +`@pytest.mark.iio_hardware([chip])` markers and subprocess `runHWTests`. -The mapping from a labgrid place (tagged with `carrier` / `daughter-board`) to -the MATLAB board reference name that `runHWTests` expects lives in -[`test/hw_ci/board_map.yaml`](test/hw_ci/board_map.yaml). +Per-place env yamls live in [`test/hw/env/`](test/hw/env/) — one file per +coordinator place, declaring the labgrid drivers + boot strategy inline. -Run locally against a coordinator place: +### Run locally ```bash -pip install "adi-labgrid-plugins @ git+https://github.com/tfcollins/labgrid-plugins.git@v2" - -adi-lg-matlab run \ - --coord $LG_COORDINATOR --place mini2 \ - --board-map test/hw_ci/board_map.yaml \ - --repo-dir . --matlab /opt/MATLAB/R2025b/bin/matlab \ - --junit junit-mini2.xml --acquire +pip install -r test/hw/requirements_dev.txt + +# Acquire the lab place, run the marker-filtered bridge test, release. +labgrid-client -x $LG_COORDINATOR -p nemo acquire +LG_COORDINATOR=10.0.0.41:20408 LG_ENV=test/hw/env/nemo.yaml \ + HW_DAUGHTER=adrv9009 \ + MATLAB_BIN=/mnt/onetb/MATLAB/R2025b/bin/matlab \ + pytest test/hw/ -v --junitxml=junit-nemo.xml +labgrid-client -x $LG_COORDINATOR -p nemo release ``` -This boots the board, sets `IIO_URI`, runs `runHWTests`, copies the JUnit -results, and releases the place. The GitHub Actions equivalent is -[`.github/workflows/hw-matlab.yml`](.github/workflows/hw-matlab.yml). See the -[MATLAB Hardware CI guide](https://adi-labgrid-plugins.readthedocs.io/en/latest/user-guide/matlab-hw-ci.html) -for details. +The `conftest.py` transitions the place's `Strategy` driver to `shell`, polls +the booted board for its DHCP-assigned IP, and yields `iio_uri = "ip:"`. +Bridge tests use it to launch MATLAB (`matlab -batch "runHWTests(getenv('board'))"`) +and copy MATLAB's `_HWTestResults.xml` into a per-test JUnit. On +session finish the strategy transitions back to `powered_off`. + +When `LG_ENV` is unset, `conftest.py` is a no-op and the bridge tests skip +cleanly — existing non-labgrid invocations (`make test`, plain `runHWTests` in +a MATLAB session, etc.) are completely unaffected. + +### CI + +[`.github/workflows/hardware-test.yml`](.github/workflows/hardware-test.yml) +mirrors pyadi-iio's standalone workflow: a `preflight` job probes the +coordinator, intersects against `test/hw/env/*.yaml`, and emits a per-place +matrix; each `hw-coord ()` shard runs on its `hw-` self-hosted +runner, acquires the place, runs the marker-filtered pytest, releases. On +PRs the workflow is gated on the `hw-test` label. diff --git a/test/hw/__init__.py b/test/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/hw/_matlab_bridge.py b/test/hw/_matlab_bridge.py new file mode 100644 index 00000000..6235eb41 --- /dev/null +++ b/test/hw/_matlab_bridge.py @@ -0,0 +1,76 @@ +"""Shared helper: launch ``runHWTests`` against a libIIO URI from pytest. + +Each ``test/hw/test_*.py`` calls :func:`run_matlab_hw_tests` with the +MATLAB board reference name it targets (matching one of the entries in +``test/runHWTests.m``'s switch). The helper sets ``IIO_URI`` / +``board`` in the subprocess env, runs MATLAB in ``-batch`` mode, and +copies the MATLAB-side JUnit (``_HWTestResults.xml``) into a +per-test JUnit so the workflow's aggregator picks it up. + +``runHWTests.m`` exit codes (see test/runHWTests.m:103-121): + 0 = all passed, 2 = one or more failed, 3 = one or more Incomplete + (e.g. CheckDevice's ``assumeFail`` when the board isn't reachable). + +Treat 0 and 3 as non-failures at the pytest level; the JUnit itself +reflects skipped status and downstream reporters surface it. Anything +else (real failure or MATLAB crash) fails the test loudly. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MATLAB = os.environ.get("MATLAB_BIN", "matlab") +_BATCH_BODY = "addpath(genpath('test')); runHWTests(getenv('board'))" + + +def run_matlab_hw_tests( + iio_uri: str, + matlab_board: str, + *, + junit_dest: Path, + extra_env: dict[str, str] | None = None, +) -> None: + """Launch MATLAB ``runHWTests(matlab_board)`` against ``iio_uri``. + + ``junit_dest`` is the file the workflow's ``--junitxml`` would + write — we drop the MATLAB-produced JUnit there so the per-place + aggregation picks it up under one filename. MATLAB's + ``XMLPlugin.producingJUnitFormat`` output IS JUnit, so no format + conversion is required. + """ + env = {**os.environ} + env["IIO_URI"] = iio_uri + env["board"] = matlab_board + if extra_env: + env.update(extra_env) + + cmd = [DEFAULT_MATLAB, "-nodisplay", "-nosplash", "-batch", _BATCH_BODY] + proc = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env, check=False) + + # MATLAB writes _HWTestResults.xml in cwd (REPO_ROOT). Copy + # it into the requested junit path so the workflow can pick it up + # via --junitxml's expected location AND the artifact upload globs. + src = REPO_ROOT / f"{matlab_board}_HWTestResults.xml" + if src.is_file(): + junit_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, junit_dest) + + if proc.returncode == 0: + return + if proc.returncode == 3: + # Incomplete = CheckDevice graceful skip; JUnit reflects it. + pytest.skip( + f"runHWTests reported Incomplete (exit 3) — board likely " + f"unreachable at {iio_uri}; see JUnit for per-test detail" + ) + raise AssertionError( + f"runHWTests({matlab_board!r}) exited {proc.returncode}; " + f"see {junit_dest} for per-test detail" + ) diff --git a/test/hw/conftest.py b/test/hw/conftest.py new file mode 100644 index 00000000..0202a251 --- /dev/null +++ b/test/hw/conftest.py @@ -0,0 +1,171 @@ +"""Hardware-CI conftest for TransceiverToolbox. + +Mirrors the pyadi-iio HW-CI lifecycle (see +pyadi-iio/test/conftest.py:86-224) but adapts it for MATLAB: + +* No pytest-libiio. Resolved URI is exposed via an `iio_uri` session + fixture instead of `config.option.uri`. +* Active only when ``LG_ENV`` is set (CI hardware runs); when unset + every fixture/hook is a no-op so existing MATLAB unit/non-HW + invocations are untouched. + +Flow: + +1. ``pytest_configure`` loads ``LG_ENV`` via labgrid ``Environment``, + transitions the place's ``Strategy`` driver to ``"shell"``, polls + the booted board for its DHCP-assigned IPv4, and stashes + ``f"ip:{ip}"`` for the fixture. +2. The session-scoped ``iio_uri`` fixture returns that string. Bridge + tests inject it directly into the MATLAB subprocess environment. +3. ``pytest_sessionfinish`` transitions the strategy to ``"powered_off"`` + regardless of pass/fail. +""" + +from __future__ import annotations + +import os +import re +import time +import warnings + +import pytest + +_LG_ENV = os.environ.get("LG_ENV") +_lg_strategy = None # captured in pytest_configure for sessionfinish teardown +_resolved_uri: str | None = None + + +def _wait_for_ipv4(shell, timeout: int = 60) -> str: + """Poll the booted board for a valid DHCP-assigned IPv4 address. + + ``shell.run()`` captures both stdout and stderr from the serial + console, so transient errors like ``RTNETLINK answers: Network is + unreachable`` can land in the output before the link is up — + validate the line is dotted-quad before accepting it. + """ + ipv4_re = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") + deadline = time.time() + timeout + while time.time() < deadline: + out, _, _ = shell.run( + "ip -4 -o route get 1.1.1.1 2>/dev/null " + "| awk '{for(i=1;i<=NF;i++) if($i==\"src\") print $(i+1); exit}'" + ) + for line in out or []: + line = line.strip() + if ipv4_re.match(line): + return line + time.sleep(2) + return "" + + +def _do_labgrid_boot() -> None: + """One attempt at full labgrid boot + IP-readiness + URI publish.""" + global _lg_strategy, _resolved_uri + from labgrid import Environment # lazy: only needed for HW runs + + env = Environment(_LG_ENV) + target = env.get_target("main") + + # The transition target is configurable via $LG_TRANSITION_TO: + # "skip" (default) — never transition; rely on the board being + # already booted (auto-boot Kuiper SD case). + # Just resolves NetworkService.address. + # "booted" — BootFPGASoCSSH-style: power-cycle and wait + # for the Linux kernel banner. Use this when + # the lab needs a known-fresh boot. + # "shell" — pyadi-iio's full-transition default + # (BootFPGASoC's SD-mux flash + boot). + # The matching strategy is determined by the env yaml — `Strategy` + # is whatever boot-strategy class is declared under `drivers:`. + transition_to = os.environ.get("LG_TRANSITION_TO", "skip") + if transition_to and transition_to != "skip": + _lg_strategy = target.get_driver("Strategy") + _lg_strategy.transition(transition_to) + + # Resolve the board's libIIO URI. Two paths: + # + # 1. Shell-poll (pyadi-iio's path): ask the booted board for its + # own DHCP-assigned IP via `ip route get`. Only meaningful when + # a transition was performed AND the strategy activated a + # CommandProtocol — when LG_TRANSITION_TO=skip there's no + # active shell to query. + # 2. Resource lookup: read the place's `NetworkService.address` + # directly. The exporter publishes this on the coordinator; + # works regardless of boot state for stable-DHCP labs. + # + # Prefer (1) when we actually transitioned; fall through to (2) + # otherwise (and as a fallback when shell-poll fails). + ip = "" + if _lg_strategy is not None: + try: + shell = target.get_driver("CommandProtocol") + ip = _wait_for_ipv4(shell, timeout=60) + except Exception as e: # noqa: BLE001 + warnings.warn( + f"shell-based IP poll failed ({e!r}); falling back to " + f"NetworkService.address", + stacklevel=1, + ) + if not ip: + try: + ns = target.get_resource("NetworkService") + ip = ns.address + except Exception as e: # noqa: BLE001 + raise RuntimeError( + f"could not resolve board IP (shell poll empty, " + f"NetworkService lookup failed: {e!r})" + ) from e + if not ip: + raise RuntimeError("could not resolve a board IP from shell or NetworkService") + _resolved_uri = f"ip:{ip}" + + +def pytest_configure(config: pytest.Config) -> None: + """When ``LG_ENV`` is set, boot the lab board and stash its URI. + + Wrapped in a small retry loop because the lab-side path is flaky: + mDNS resolution of place hosts, SSH ControlMaster bring-up, etc. + all surface as transient socket errors that succeed on a retry. + """ + if not _LG_ENV: + return + attempts = 3 + last_err: Exception | None = None + for i in range(1, attempts + 1): + try: + _do_labgrid_boot() + return + except Exception as e: # noqa: BLE001 + last_err = e + if i < attempts: + time.sleep(10) + pytest.fail(f"labgrid boot failed after {attempts} attempts: {last_err!r}") + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + """Power the board off after the last test, regardless of pass/fail. + + The three strategies in use (BootFPGASoC, BootFPGASoCTFTP, + BootFabric) all define ``Status.powered_off`` so the same string + transition works across legs. Cleanup errors are warned, never + re-raised, so a flaky power-off can't mask a real test failure. + """ + if _lg_strategy is None: + return + try: + _lg_strategy.transition("powered_off") + except Exception as e: # noqa: BLE001 + warnings.warn(f"strategy power-off failed: {e}", stacklevel=1) + + +@pytest.fixture(scope="session") +def iio_uri() -> str: + """The libIIO URI of the booted board (``ip:``). + + Skips the test when no labgrid boot was performed (``LG_ENV`` + unset, or boot failed but pytest_configure didn't fail-hard for + some reason). + """ + if _resolved_uri is None: + pytest.skip("no labgrid-resolved iio_uri (LG_ENV not set?)") + return _resolved_uri diff --git a/test/hw/env/bq.yaml b/test/hw/env/bq.yaml new file mode 100644 index 00000000..65ecd582 --- /dev/null +++ b/test/hw/env/bq.yaml @@ -0,0 +1,34 @@ +## TransceiverToolbox HW-CI env for ADRV9371 + ZC706 via the `bq` +## remote place on the labgrid coordinator. +## +## Run locally: +## labgrid-client -x 10.0.0.41:20408 -p bq acquire +## LG_COORDINATOR=10.0.0.41:20408 LG_ENV=test/hw/env/bq.yaml \ +## HW_DAUGHTER=adrv9371 \ +## pytest test/hw/ -v +## labgrid-client -x 10.0.0.41:20408 -p bq release +## +## Same shape as nemo.yaml — only the daughter board (features + +## marker) and the RemotePlace name differ. + +targets: + main: + features: + - adrv9371 + - zc706 + resources: + RemotePlace: + name: bq + drivers: + HomeAssistantPowerDriver: {} + SerialDriver: {} + ADIShellDriver: + prompt: 'root@.*' + login_prompt: 'analog login: ' + username: 'root' + password: 'analog' + KuiperDLDriver: {} + SSHDriver: {} + BootFPGASoCSSH: + reached_linux_marker: 'analog' + wait_for_linux_prompt_timeout: 180 diff --git a/test/hw/env/nemo.yaml b/test/hw/env/nemo.yaml new file mode 100644 index 00000000..f23c12c5 --- /dev/null +++ b/test/hw/env/nemo.yaml @@ -0,0 +1,42 @@ +## TransceiverToolbox HW-CI env for ADRV9009 + ZC706 via the `nemo` +## remote place on the labgrid coordinator. +## +## Run locally: +## labgrid-client -x 10.0.0.41:20408 -p nemo acquire +## LG_COORDINATOR=10.0.0.41:20408 LG_ENV=test/hw/env/nemo.yaml \ +## HW_DAUGHTER=adrv9009 \ +## pytest test/hw/ -v +## labgrid-client -x 10.0.0.41:20408 -p nemo release +## +## `adrv9009` is in features so `adi_lg_plugins.pytest_plugin` (auto- +## registered) keeps tests whose `@pytest.mark.iio_hardware([...])` +## includes that chip and skips the rest. +## +## Strategy: BootFPGASoCSSH. The board's SD carries a working Kuiper +## image that auto-boots straight to Linux (root@analog:~#), so we +## don't need to flash anything, JTAG-bootstrap U-Boot, or TFTP a +## kernel — just power-cycle and wait for the Linux marker. The +## conftest deliberately calls `transition("booted")` (NOT "shell") +## so the strategy's SSH file-update path is skipped. + +targets: + main: + features: + - adrv9009 + - zc706 + resources: + RemotePlace: + name: nemo + drivers: + HomeAssistantPowerDriver: {} + SerialDriver: {} + ADIShellDriver: + prompt: 'root@.*' + login_prompt: 'analog login: ' + username: 'root' + password: 'analog' + KuiperDLDriver: {} + SSHDriver: {} + BootFPGASoCSSH: + reached_linux_marker: 'analog' + wait_for_linux_prompt_timeout: 180 diff --git a/test/hw/requirements_dev.txt b/test/hw/requirements_dev.txt new file mode 100644 index 00000000..27302c95 --- /dev/null +++ b/test/hw/requirements_dev.txt @@ -0,0 +1,18 @@ +# Hardware-CI Python dependencies for TransceiverToolbox. +# +# Installed by the hardware-test workflow into a uv-managed venv on +# each `hw-` runner. Used by `test/hw/conftest.py` (labgrid +# boot lifecycle) and the per-board bridge tests (subprocess-launch +# `runHWTests`). +# +# `[kuiper]` extra pulls in pytsk3 (manylinux wheels), which +# KuiperDLDriver / ImageExtractor use to extract uImage + devicetree +# from the cached Kuiper SD image during the `BootFPGASoCTFTP` boot. +# +# Pin to @v2: the same ref used by every other ADI HW-CI consumer. +# Fast-forward `v2` from `main` when upstream labgrid-plugins changes +# need to land in the lab. + +pytest>=7 +pyyaml +adi-labgrid-plugins[kuiper] @ git+https://github.com/tfcollins/labgrid-plugins.git@v2 diff --git a/test/hw/test_zynq_zc706_adv7511_adrv9009_hw.py b/test/hw/test_zynq_zc706_adv7511_adrv9009_hw.py new file mode 100644 index 00000000..c395e102 --- /dev/null +++ b/test/hw/test_zynq_zc706_adv7511_adrv9009_hw.py @@ -0,0 +1,27 @@ +"""Hardware test: ADRV9009 + ZC706 (Kuiper image ``zynq-zc706-adv7511-adrv9009``). + +Marked with ``iio_hardware(["adrv9009"])`` so +``adi_lg_plugins.pytest_plugin`` (auto-registered via ``pytest11``) +filters this test out when the discovered place's ``daughter-board`` +tag is not ``adrv9009`` — i.e. only the ``nemo`` shard runs it. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ._matlab_bridge import run_matlab_hw_tests + +MATLAB_BOARD = "zynq-zc706-adv7511-adrv9009" + + +@pytest.mark.iio_hardware(["adrv9009"]) +def test_adrv9009_zc706_runHWTests(iio_uri, tmp_path: Path) -> None: + """Run ``runHWTests('zynq-zc706-adv7511-adrv9009')`` against the booted board.""" + run_matlab_hw_tests( + iio_uri, + MATLAB_BOARD, + junit_dest=tmp_path / "matlab-junit.xml", + ) diff --git a/test/hw/test_zynq_zc706_adv7511_adrv9371_hw.py b/test/hw/test_zynq_zc706_adv7511_adrv9371_hw.py new file mode 100644 index 00000000..ca6b2816 --- /dev/null +++ b/test/hw/test_zynq_zc706_adv7511_adrv9371_hw.py @@ -0,0 +1,27 @@ +"""Hardware test: ADRV9371 + ZC706 (Kuiper image ``zynq-zc706-adv7511-adrv9371``). + +Marked with ``iio_hardware(["adrv9371"])`` so +``adi_lg_plugins.pytest_plugin`` filters this test out when the +discovered place's ``daughter-board`` tag is not ``adrv9371`` — +i.e. only the ``bq`` shard runs it. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ._matlab_bridge import run_matlab_hw_tests + +MATLAB_BOARD = "zynq-zc706-adv7511-adrv9371" + + +@pytest.mark.iio_hardware(["adrv9371"]) +def test_adrv9371_zc706_runHWTests(iio_uri, tmp_path: Path) -> None: + """Run ``runHWTests('zynq-zc706-adv7511-adrv9371')`` against the booted board.""" + run_matlab_hw_tests( + iio_uri, + MATLAB_BOARD, + junit_dest=tmp_path / "matlab-junit.xml", + ) diff --git a/test/hw_ci/board_map.yaml b/test/hw_ci/board_map.yaml deleted file mode 100644 index 58bc0ea6..00000000 --- a/test/hw_ci/board_map.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# TransceiverToolbox board map for labgrid HW-CI (adi-lg-matlab). -# -# Maps a labgrid coordinator place's tags -> the MATLAB board reference -# name that `runHWTests(board)` understands (the values in the `switch` -# in test/runHWTests.m). adi-lg-matlab uses this to translate a booted -# place into the right `board` argument. -# -# Matching rules (see adi_lg_plugins.matlab_ci.board_map): -# * `daughter-board` is required and matched against the place's -# `daughter-board` tag. -# * `carrier` / `hdl-config`, when present, must also match the place's -# tags; the MOST specific matching row wins. A row without `carrier` -# is a carrier-agnostic fallback. -# -# The `daughter-board` / `carrier` values below must match the tags the -# lab admin set on each place, e.g.: -# labgrid-client -p mini2 set-tags carrier=zcu102 daughter-board=adrv9002 \ -# boot-strategy=BootFPGASoC -# -# NOTE: this duplicates knowledge in test/runHWTests.m's switch — keep the -# two in sync when adding boards. - -boards: - # --- AD9361 (FMComms2/3) --- - - {carrier: zcu102, daughter-board: ad9361, matlab_board: zynqmp-zcu102-rev10-ad9361-fmcomms2-3} - - {carrier: zc706, daughter-board: ad9361, matlab_board: zynq-zc706-adv7511-ad9361-fmcomms2-3} - - {carrier: zc702, daughter-board: ad9361, matlab_board: zynq-zc702-adv7511-ad9361-fmcomms2-3} - - {carrier: zed, daughter-board: ad9361, matlab_board: zynq-zed-adv7511-ad9361-fmcomms2-3} - - # --- AD9364 (FMComms4) --- - - {carrier: zcu102, daughter-board: ad9364, matlab_board: zynqmp-zcu102-rev10-ad9364-fmcomms4} - - {carrier: zc706, daughter-board: ad9364, matlab_board: zynq-zc706-adv7511-ad9364-fmcomms4} - - {carrier: zed, daughter-board: ad9364, matlab_board: zynq-zed-adv7511-ad9364-fmcomms4} - - # --- FMComms5 (dual AD9361) --- - - {carrier: zcu102, daughter-board: fmcomms5, matlab_board: zynqmp-zcu102-rev10-ad9361-fmcomms5} - - {carrier: zc706, daughter-board: fmcomms5, matlab_board: zynq-zc706-adv7511-ad9361-fmcomms5} - - # --- AD9371 / ADRV9371 --- - - {carrier: zcu102, daughter-board: adrv9371, matlab_board: zynqmp-zcu102-rev10-adrv9371} - - {carrier: zc706, daughter-board: adrv9371, matlab_board: zynq-zc706-adv7511-adrv9371} - - # --- ADRV9002 (CMOS default; hdl-config selects LVDS vs CMOS) --- - - {carrier: zcu102, daughter-board: adrv9002, matlab_board: zynqmp-zcu102-rev10-adrv9002-vcmos} - - {carrier: zcu102, daughter-board: adrv9002, hdl-config: lvds, matlab_board: zynqmp-zcu102-rev10-adrv9002-vlvds} - - {carrier: zcu102, daughter-board: adrv9002, hdl-config: cmos, matlab_board: zynqmp-zcu102-rev10-adrv9002-vcmos} - - {carrier: zed, daughter-board: adrv9002, matlab_board: zynq-zed-adv7511-adrv9002-vcmos} - - # --- ADRV9009 --- - - {carrier: zcu102, daughter-board: adrv9009, matlab_board: zynqmp-zcu102-rev10-adrv9009} - - {carrier: zc706, daughter-board: adrv9009, matlab_board: zynq-zc706-adv7511-adrv9009} - - # --- FMComms8 (dual ADRV9009) --- - - {carrier: zcu102, daughter-board: fmcomms8, matlab_board: zynqmp-zcu102-rev10-adrv9009-fmcomms8} - - # --- Pluto (self-contained) --- - - {daughter-board: pluto, matlab_board: pluto}