Skip to content

Commit 6b0383c

Browse files
ymodlinclaude
andauthored
Add V4L2 on-device test workflow and self-hosted runner setup (#369)
- Add GitHub Actions workflow that runs V4L2 pytest suite on a self-hosted Jetson runner (driver reload, sanity check, pytest, dmesg collection, artifact upload) - Add setup script for installing GitHub Actions runner on Jetson with proper labels and passwordless sudo for driver operations - Supports manual trigger with test filter and auto-trigger on push/PR to kernel/realsense or test/v4l2_test paths Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6dc94c commit 6b0383c

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

.github/workflows/v4l2-test.yml

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
name: V4L2 On-Device Tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
test_filter:
7+
description: 'Pytest -k filter (e.g. "streaming", "test_depth_streaming"). Leave empty for all tests.'
8+
required: false
9+
default: ''
10+
push:
11+
branches: [master, dev]
12+
paths:
13+
- 'kernel/realsense/**'
14+
- 'test/v4l2_test/**'
15+
pull_request:
16+
branches: [master, dev]
17+
paths:
18+
- 'kernel/realsense/**'
19+
- 'test/v4l2_test/**'
20+
21+
permissions: read-all
22+
23+
jobs:
24+
v4l2-test:
25+
runs-on: [self-hosted, jetson, xavier]
26+
timeout-minutes: 30
27+
28+
env:
29+
TEST_DIR: test/v4l2_test
30+
RESULTS_DIR: v4l2_test_results
31+
32+
steps:
33+
- name: Checkout
34+
uses: actions/checkout@v4
35+
36+
- name: Create results directory
37+
run: mkdir -p "$RESULTS_DIR"
38+
39+
- name: Collect system info
40+
run: |
41+
echo "=== System Information ==="
42+
echo "Hostname: $(hostname)"
43+
echo "Kernel: $(uname -r)"
44+
echo "Date: $(date -Iseconds)"
45+
echo ""
46+
47+
echo "--- JetPack / L4T ---"
48+
cat /etc/nv_tegra_release 2>/dev/null || echo "N/A"
49+
echo ""
50+
51+
echo "--- Video devices ---"
52+
ls -la /dev/video* 2>/dev/null || echo "No video devices found"
53+
echo ""
54+
55+
echo "--- D4XX module ---"
56+
lsmod | grep d4xx || echo "d4xx module not loaded"
57+
58+
# Save structured info for artifact
59+
python3 -c "
60+
import json, subprocess, datetime
61+
info = {
62+
'timestamp': datetime.datetime.now().isoformat(),
63+
'hostname': '$(hostname)',
64+
'kernel': '$(uname -r)',
65+
}
66+
r = subprocess.run(['cat', '/etc/nv_tegra_release'], capture_output=True, text=True)
67+
info['jetpack'] = r.stdout.strip() if r.returncode == 0 else 'N/A'
68+
r = subprocess.run(['bash', '-c', 'ls /dev/video* 2>/dev/null'], capture_output=True, text=True)
69+
info['video_devices'] = r.stdout.strip().split('\n') if r.stdout.strip() else []
70+
r = subprocess.run(['bash', '-c', 'lsmod | grep d4xx'], capture_output=True, text=True)
71+
info['d4xx_module'] = r.stdout.strip()
72+
with open('$RESULTS_DIR/system_info.json', 'w') as f:
73+
json.dump(info, f, indent=2)
74+
"
75+
76+
- name: Reload D4XX driver
77+
run: |
78+
echo "Removing d4xx module..."
79+
sudo rmmod d4xx 2>/dev/null || echo "Module was not loaded"
80+
sleep 1
81+
82+
echo "Loading d4xx module..."
83+
sudo modprobe d4xx
84+
sleep 2
85+
86+
echo "Driver status:"
87+
lsmod | grep d4xx
88+
89+
- name: Hardware reset
90+
run: |
91+
if [ -e /dev/video0 ]; then
92+
v4l2-ctl -d /dev/video0 --set-ctrl=hw_reset=1 2>/dev/null || echo "hw_reset not supported (non-fatal)"
93+
sleep 2
94+
else
95+
echo "Warning: /dev/video0 not found, skipping hw_reset"
96+
fi
97+
98+
- name: Sanity check
99+
id: sanity
100+
run: |
101+
echo "=== Sanity Check ==="
102+
PASS=true
103+
104+
# 1. Video devices exist
105+
echo "Checking video devices..."
106+
if ls /dev/video* >/dev/null 2>&1; then
107+
echo "OK: Video devices found"
108+
ls /dev/video*
109+
else
110+
echo "FAIL: No video devices"
111+
PASS=false
112+
fi
113+
114+
# 2. D4XX module loaded
115+
echo ""
116+
echo "Checking D4XX driver..."
117+
if lsmod | grep -q d4xx; then
118+
echo "OK: d4xx module loaded"
119+
else
120+
echo "FAIL: d4xx module not loaded"
121+
PASS=false
122+
fi
123+
124+
# 3. Camera responds to V4L2
125+
echo ""
126+
echo "Querying camera capabilities..."
127+
if v4l2-ctl -d /dev/video0 --info 2>/dev/null; then
128+
echo "OK: Camera responds"
129+
else
130+
echo "FAIL: Camera not responding"
131+
PASS=false
132+
fi
133+
134+
# 4. Quick stream test (1 frame)
135+
echo ""
136+
echo "Testing basic streaming..."
137+
if timeout 10 v4l2-ctl -d /dev/video0 \
138+
--set-fmt-video=width=640,height=480 \
139+
--stream-mmap --stream-count=1 2>&1; then
140+
echo "OK: Captured test frame"
141+
else
142+
echo "FAIL: Cannot stream from camera"
143+
PASS=false
144+
fi
145+
146+
if [ "$PASS" = false ]; then
147+
echo ""
148+
echo "SANITY CHECK FAILED - camera is not operational"
149+
echo "sanity_passed=false" >> "$GITHUB_OUTPUT"
150+
exit 1
151+
fi
152+
153+
echo ""
154+
echo "SANITY CHECK PASSED"
155+
echo "sanity_passed=true" >> "$GITHUB_OUTPUT"
156+
157+
- name: Run V4L2 pytest suite
158+
if: steps.sanity.outputs.sanity_passed == 'true'
159+
run: |
160+
cd test
161+
162+
PYTEST_ARGS="-vs --tb=short -m d457 v4l2_test/"
163+
164+
if [ -n "${{ github.event.inputs.test_filter }}" ]; then
165+
PYTEST_ARGS="$PYTEST_ARGS -k '${{ github.event.inputs.test_filter }}'"
166+
fi
167+
168+
echo "Running: python3 -m pytest $PYTEST_ARGS"
169+
echo ""
170+
171+
# Run pytest; tee output to file for artifact, allow non-zero exit
172+
python3 -m pytest $PYTEST_ARGS 2>&1 | tee "../$RESULTS_DIR/pytest_output.log" || true
173+
174+
# Capture actual exit code by re-running with --co (collect only) to not re-run
175+
# Instead, parse the log for the summary line
176+
if grep -q "failed" "../$RESULTS_DIR/pytest_output.log"; then
177+
echo "::warning::Some tests failed - see pytest_output.log artifact"
178+
fi
179+
180+
- name: Collect dmesg logs
181+
if: always()
182+
run: |
183+
sudo dmesg -T | grep -E '(d4xx|D4XX|GMSL|V4L2|video|media|tegra-camrtc|nvcsi|vi5)' \
184+
| tail -500 > "$RESULTS_DIR/dmesg_filtered.log" 2>/dev/null || true
185+
186+
sudo dmesg -T | tail -200 > "$RESULTS_DIR/dmesg_tail.log" 2>/dev/null || true
187+
188+
echo "Dmesg logs collected"
189+
190+
- name: Generate test summary
191+
if: always()
192+
run: |
193+
python3 -c "
194+
import re, sys
195+
196+
log_path = '$RESULTS_DIR/pytest_output.log'
197+
try:
198+
with open(log_path) as f:
199+
content = f.read()
200+
except FileNotFoundError:
201+
print('No pytest output (sanity check may have failed)')
202+
sys.exit(0)
203+
204+
# Parse summary line like: '== 19 passed, 38 failed, 7 skipped in 45.32s =='
205+
m = re.search(r'=+\s*(.*?)\s*=+\s*$', content, re.MULTILINE)
206+
if m:
207+
summary = m.group(1)
208+
print(f'## Test Summary\n\n{summary}\n')
209+
210+
# Extract counts
211+
passed = re.search(r'(\d+) passed', summary)
212+
failed = re.search(r'(\d+) failed', summary)
213+
skipped = re.search(r'(\d+) skipped', summary)
214+
215+
p = int(passed.group(1)) if passed else 0
216+
f = int(failed.group(1)) if failed else 0
217+
s = int(skipped.group(1)) if skipped else 0
218+
total = p + f + s
219+
220+
print(f'| Metric | Count |')
221+
print(f'|--------|-------|')
222+
print(f'| Passed | {p} |')
223+
print(f'| Failed | {f} |')
224+
print(f'| Skipped | {s} |')
225+
print(f'| Total | {total} |')
226+
227+
if f > 0:
228+
print(f'\n### Failed Tests\n')
229+
for line in content.split('\n'):
230+
if 'FAILED' in line:
231+
print(f'- {line.strip()}')
232+
else:
233+
print('Could not parse pytest summary')
234+
" | tee "$RESULTS_DIR/summary.md"
235+
236+
# Write to GitHub step summary
237+
if [ -f "$RESULTS_DIR/summary.md" ]; then
238+
cat "$RESULTS_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
239+
fi
240+
241+
- name: Upload test artifacts
242+
if: always()
243+
uses: actions/upload-artifact@v4
244+
with:
245+
name: v4l2-test-results-${{ github.run_number }}
246+
path: |
247+
${{ env.RESULTS_DIR }}/
248+
retention-days: 30

scripts/setup_gh_runner.sh

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/bin/bash
2+
#
3+
# Setup a GitHub Actions self-hosted runner on a Jetson device.
4+
#
5+
# Usage (run ON the Jetson, or via SSH):
6+
# ./scripts/setup_gh_runner.sh <GITHUB_RUNNER_TOKEN>
7+
#
8+
# The token is a one-time registration token generated from:
9+
# GitHub repo -> Settings -> Actions -> Runners -> New self-hosted runner
10+
# Or via CLI:
11+
# gh api -X POST repos/OWNER/REPO/actions/runners/registration-token --jq .token
12+
#
13+
# Prerequisites:
14+
# - Jetson running Ubuntu (aarch64)
15+
# - Internet access
16+
# - sudo privileges
17+
# - python3 + pytest installed (for running tests)
18+
#
19+
set -euo pipefail
20+
21+
# ---- Configuration ----
22+
RUNNER_VERSION="2.322.0"
23+
RUNNER_ARCH="arm64"
24+
REPO_URL="https://github.com/realsenseai/realsense_mipi_platform_driver"
25+
RUNNER_DIR="${HOME}/actions-runner"
26+
RUNNER_USER="$(whoami)"
27+
28+
# Labels identify this runner to workflow `runs-on:` selectors
29+
RUNNER_NAME="${HOSTNAME:-jetson-runner}"
30+
RUNNER_LABELS="jetson,xavier,self-hosted"
31+
32+
# ---- Parse args ----
33+
if [ $# -lt 1 ]; then
34+
echo "Usage: $0 <GITHUB_RUNNER_TOKEN>"
35+
echo ""
36+
echo "Generate a token at:"
37+
echo " ${REPO_URL}/settings/actions/runners/new"
38+
echo ""
39+
echo "Or via gh CLI:"
40+
echo " gh api -X POST repos/realsenseai/realsense_mipi_platform_driver/actions/runners/registration-token --jq .token"
41+
exit 1
42+
fi
43+
44+
TOKEN="$1"
45+
46+
echo "============================================================"
47+
echo " GitHub Actions Self-Hosted Runner Setup"
48+
echo "============================================================"
49+
echo " Runner name: ${RUNNER_NAME}"
50+
echo " Labels: ${RUNNER_LABELS}"
51+
echo " Repo: ${REPO_URL}"
52+
echo " Install dir: ${RUNNER_DIR}"
53+
echo " Architecture: ${RUNNER_ARCH}"
54+
echo "============================================================"
55+
echo ""
56+
57+
# ---- Install system dependencies ----
58+
echo "[1/6] Installing system dependencies..."
59+
sudo apt-get update -qq
60+
sudo apt-get install -y -qq \
61+
curl jq python3 python3-pip v4l-utils \
62+
libicu-dev libkrb5-dev zlib1g-dev 2>/dev/null
63+
64+
# Ensure pytest is available
65+
if ! python3 -m pytest --version >/dev/null 2>&1; then
66+
echo "Installing pytest..."
67+
pip3 install pytest --break-system-packages 2>/dev/null || pip3 install pytest
68+
fi
69+
70+
# ---- Configure passwordless sudo for runner operations ----
71+
echo ""
72+
echo "[2/6] Configuring passwordless sudo for driver operations..."
73+
SUDOERS_FILE="/etc/sudoers.d/github-runner"
74+
if [ ! -f "$SUDOERS_FILE" ]; then
75+
sudo tee "$SUDOERS_FILE" > /dev/null <<SUDOERS
76+
# Allow GitHub Actions runner to reload D4XX driver and read dmesg
77+
${RUNNER_USER} ALL=(ALL) NOPASSWD: /sbin/rmmod d4xx
78+
${RUNNER_USER} ALL=(ALL) NOPASSWD: /sbin/modprobe d4xx
79+
${RUNNER_USER} ALL=(ALL) NOPASSWD: /usr/bin/dmesg
80+
${RUNNER_USER} ALL=(ALL) NOPASSWD: /bin/dmesg
81+
SUDOERS
82+
sudo chmod 0440 "$SUDOERS_FILE"
83+
echo "Created ${SUDOERS_FILE}"
84+
else
85+
echo "Sudoers file already exists, skipping"
86+
fi
87+
88+
# ---- Download runner ----
89+
echo ""
90+
echo "[3/6] Downloading GitHub Actions runner v${RUNNER_VERSION}..."
91+
mkdir -p "${RUNNER_DIR}"
92+
cd "${RUNNER_DIR}"
93+
94+
TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"
95+
if [ ! -f "${TARBALL}" ]; then
96+
curl -sL -o "${TARBALL}" \
97+
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${TARBALL}"
98+
echo "Downloaded ${TARBALL}"
99+
else
100+
echo "Runner tarball already exists, skipping download"
101+
fi
102+
103+
echo "Extracting..."
104+
tar xzf "${TARBALL}" --overwrite
105+
106+
# ---- Configure runner ----
107+
echo ""
108+
echo "[4/6] Configuring runner..."
109+
./config.sh \
110+
--url "${REPO_URL}" \
111+
--token "${TOKEN}" \
112+
--name "${RUNNER_NAME}" \
113+
--labels "${RUNNER_LABELS}" \
114+
--work "_work" \
115+
--replace \
116+
--unattended
117+
118+
# ---- Install as systemd service ----
119+
echo ""
120+
echo "[5/6] Installing as systemd service..."
121+
sudo ./svc.sh install "${RUNNER_USER}"
122+
sudo ./svc.sh start
123+
124+
echo ""
125+
echo "[6/6] Verifying runner status..."
126+
sudo ./svc.sh status
127+
128+
echo ""
129+
echo "============================================================"
130+
echo " Setup complete!"
131+
echo "============================================================"
132+
echo ""
133+
echo " Runner '${RUNNER_NAME}' is registered and running."
134+
echo ""
135+
echo " The GitHub Actions workflow will match this runner with:"
136+
echo " runs-on: [self-hosted, jetson, xavier]"
137+
echo ""
138+
echo " Management commands:"
139+
echo " cd ${RUNNER_DIR}"
140+
echo " sudo ./svc.sh status # Check status"
141+
echo " sudo ./svc.sh stop # Stop runner"
142+
echo " sudo ./svc.sh start # Start runner"
143+
echo " sudo ./svc.sh uninstall # Remove service"
144+
echo " ./config.sh remove # Unregister from GitHub"
145+
echo ""
146+
echo " Trigger a test run from GitHub:"
147+
echo " - Push to master/dev (changes to kernel/realsense/ or test/v4l2_test/)"
148+
echo " - Manual: Actions tab -> 'V4L2 On-Device Tests' -> Run workflow"
149+
echo " - CLI: gh workflow run v4l2-test.yml"
150+
echo "============================================================"

0 commit comments

Comments
 (0)