Skip to content

Commit 558ad73

Browse files
bradamillerclaude
andcommitted
Add automated test stand support and CI pipeline
- teststand.py: Test stand detection (checks /teststand_mode file) and sensor helpers for phototransistor, break-beam, and reflectance slide - All test_hw_*.py: Skip wait_for_button() in teststand mode, use automated sensor verification where available (LED, servo, reflectance) - run_all_hw_tests.py: Master runner that executes all test modules and outputs JSON results for CI parsing - ci_hardware_test.py: Host-side script that detects Pico, syncs code, enables teststand mode, runs tests, and reports pass/fail - hardware-test.yml: GitHub Actions workflow for self-hosted runner (hardware tests) and ubuntu (unit tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 056fc93 commit 558ad73

File tree

11 files changed

+562
-64
lines changed

11 files changed

+562
-64
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Hardware Integration Tests
2+
3+
on:
4+
push:
5+
paths:
6+
- 'XRPLib/**'
7+
- 'tests/hardware/**'
8+
pull_request:
9+
paths:
10+
- 'XRPLib/**'
11+
- 'tests/hardware/**'
12+
workflow_dispatch: # Allow manual trigger
13+
14+
jobs:
15+
hardware-tests:
16+
name: Run XRP Hardware Tests
17+
runs-on: self-hosted-xrp-teststand # Self-hosted runner with XRP test stand
18+
timeout-minutes: 10
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v4
23+
24+
- name: Set up Python
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: '3.11'
28+
29+
- name: Install mpremote
30+
run: pip install mpremote
31+
32+
- name: Run hardware tests
33+
run: python scripts/ci_hardware_test.py
34+
35+
unit-tests:
36+
name: Run Unit Tests
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 5
39+
40+
steps:
41+
- name: Checkout code
42+
uses: actions/checkout@v4
43+
44+
- name: Set up Python
45+
uses: actions/setup-python@v5
46+
with:
47+
python-version: '3.11'
48+
49+
- name: Install pytest
50+
run: pip install pytest
51+
52+
- name: Run unit tests
53+
run: pytest tests/unit/ -v

scripts/ci_hardware_test.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
"""
3+
CI host-side script for running hardware tests on an XRP test stand.
4+
5+
This script runs on the CI host machine (not on the Pico). It:
6+
1. Detects the Pico's serial port
7+
2. Syncs the XRPLib code to the Pico
8+
3. Creates the /teststand_mode flag file
9+
4. Uploads and runs the hardware test suite
10+
5. Parses results and returns appropriate exit code
11+
12+
Prerequisites:
13+
pip install mpremote
14+
15+
Usage:
16+
python scripts/ci_hardware_test.py
17+
"""
18+
import subprocess
19+
import sys
20+
import json
21+
import re
22+
import os
23+
24+
25+
def run_cmd(args, check=True, capture=True):
26+
"""Run a command and return its output."""
27+
print(f" > {' '.join(args)}")
28+
result = subprocess.run(
29+
args,
30+
capture_output=capture,
31+
text=True,
32+
timeout=300, # 5 minute timeout
33+
)
34+
if check and result.returncode != 0:
35+
print(f" STDERR: {result.stderr}")
36+
raise RuntimeError(f"Command failed: {' '.join(args)}")
37+
return result
38+
39+
40+
def detect_pico():
41+
"""Verify mpremote can see a connected Pico."""
42+
result = run_cmd(["mpremote", "connect", "list"])
43+
if result.stdout.strip():
44+
print(f" Detected devices:\n{result.stdout}")
45+
return True
46+
else:
47+
print(" No Pico detected!")
48+
return False
49+
50+
51+
def sync_xrplib():
52+
"""Copy XRPLib source files to the Pico's lib directory."""
53+
print("\nSyncing XRPLib to Pico...")
54+
# Create lib/XRPLib directory on Pico
55+
run_cmd(["mpremote", "mkdir", ":lib"], check=False)
56+
run_cmd(["mpremote", "mkdir", ":lib/XRPLib"], check=False)
57+
58+
# Copy all .py files from XRPLib/
59+
xrplib_dir = os.path.join(os.path.dirname(__file__), "..", "XRPLib")
60+
for filename in os.listdir(xrplib_dir):
61+
if filename.endswith(".py"):
62+
src = os.path.join(xrplib_dir, filename)
63+
dst = f":lib/XRPLib/{filename}"
64+
run_cmd(["mpremote", "cp", src, dst])
65+
print(" XRPLib synced.")
66+
67+
68+
def sync_test_files():
69+
"""Copy hardware test files to the Pico."""
70+
print("\nSyncing test files to Pico...")
71+
run_cmd(["mpremote", "mkdir", ":tests"], check=False)
72+
run_cmd(["mpremote", "mkdir", ":tests/hardware"], check=False)
73+
74+
test_dir = os.path.join(os.path.dirname(__file__), "..", "tests", "hardware")
75+
for filename in os.listdir(test_dir):
76+
if filename.endswith(".py"):
77+
src = os.path.join(test_dir, filename)
78+
dst = f":tests/hardware/{filename}"
79+
run_cmd(["mpremote", "cp", src, dst])
80+
print(" Test files synced.")
81+
82+
83+
def ensure_teststand_mode():
84+
"""Create the /teststand_mode flag file on the Pico."""
85+
print("\nSetting teststand mode...")
86+
run_cmd(["mpremote", "exec", "f = open('/teststand_mode', 'w'); f.close()"])
87+
print(" Teststand mode enabled.")
88+
89+
90+
def run_tests():
91+
"""Run the hardware test suite and capture output."""
92+
print("\nRunning hardware tests...")
93+
print("-" * 50)
94+
95+
result = run_cmd(
96+
["mpremote", "run", "tests/hardware/run_all_hw_tests.py"],
97+
check=False,
98+
)
99+
100+
print(result.stdout)
101+
if result.stderr:
102+
print(f"STDERR: {result.stderr}")
103+
104+
return result
105+
106+
107+
def parse_results(output):
108+
"""Parse the JSON results line from test output."""
109+
for line in output.split("\n"):
110+
if line.startswith("__RESULTS_JSON__:"):
111+
json_str = line[len("__RESULTS_JSON__:"):]
112+
return json.loads(json_str)
113+
return None
114+
115+
116+
def main():
117+
print("XRP Hardware Test CI Runner")
118+
print("=" * 50)
119+
120+
# Step 1: Detect Pico
121+
print("\nDetecting Pico...")
122+
if not detect_pico():
123+
print("\nFAIL: No Pico W detected. Check USB connection.")
124+
sys.exit(1)
125+
126+
# Step 2: Sync code
127+
sync_xrplib()
128+
sync_test_files()
129+
130+
# Step 3: Enable teststand mode
131+
ensure_teststand_mode()
132+
133+
# Step 4: Run tests
134+
result = run_tests()
135+
136+
# Step 5: Parse and report
137+
print("\n" + "=" * 50)
138+
results = parse_results(result.stdout)
139+
140+
if results is None:
141+
print("FAIL: Could not parse test results from output.")
142+
sys.exit(1)
143+
144+
total = results["total_passed"] + results["total_failed"]
145+
print(f"Final: {results['total_passed']}/{total} tests passed")
146+
147+
if results["total_failed"] > 0:
148+
print(f"\n{results['total_failed']} test(s) FAILED:")
149+
for mod in results["modules"]:
150+
if mod["failed"] > 0 or mod["error"]:
151+
print(f" - {mod['name']}: {mod['failed']} failed")
152+
if mod["error"]:
153+
print(f" Error: {mod['error']}")
154+
sys.exit(1)
155+
else:
156+
print("\nAll tests PASSED!")
157+
sys.exit(0)
158+
159+
160+
if __name__ == "__main__":
161+
main()

tests/hardware/run_all_hw_tests.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Master test runner for all hardware integration tests.
3+
Runs each test module sequentially and outputs a JSON summary.
4+
5+
Usage:
6+
mpremote run tests/hardware/run_all_hw_tests.py
7+
8+
In teststand mode (file /teststand_mode exists on Pico), all tests
9+
run without manual intervention.
10+
"""
11+
import sys
12+
import time
13+
import json
14+
15+
# Test modules to run, in order
16+
TEST_MODULES = [
17+
("Board", "test_hw_board"),
18+
("Motors", "test_hw_motors"),
19+
("Encoders", "test_hw_encoders"),
20+
("Drivetrain", "test_hw_drivetrain"),
21+
("Rangefinder", "test_hw_rangefinder"),
22+
("Reflectance", "test_hw_reflectance"),
23+
("IMU", "test_hw_imu"),
24+
("Servo", "test_hw_servo"),
25+
("Timing", "test_hw_timing"),
26+
]
27+
28+
results = {
29+
"total_passed": 0,
30+
"total_failed": 0,
31+
"modules": [],
32+
}
33+
34+
35+
def run_module(name, module_name):
36+
"""
37+
Run a test module by importing it. Each module prints its own
38+
PASS/FAIL lines and sets module-level `passed` and `failed` counters.
39+
"""
40+
print()
41+
print(f"{'='*50}")
42+
print(f"Running: {name}")
43+
print(f"{'='*50}")
44+
45+
module_result = {
46+
"name": name,
47+
"module": module_name,
48+
"passed": 0,
49+
"failed": 0,
50+
"error": None,
51+
}
52+
53+
try:
54+
# Each test module runs its tests on import (top-level code)
55+
mod = __import__(module_name)
56+
module_result["passed"] = getattr(mod, "passed", 0)
57+
module_result["failed"] = getattr(mod, "failed", 0)
58+
except Exception as e:
59+
module_result["error"] = str(e)
60+
module_result["failed"] = 1
61+
print(f" ERROR: {e}")
62+
63+
results["total_passed"] += module_result["passed"]
64+
results["total_failed"] += module_result["failed"]
65+
results["modules"].append(module_result)
66+
67+
return module_result["failed"] == 0 and module_result["error"] is None
68+
69+
70+
# Check teststand mode
71+
try:
72+
from teststand import is_teststand
73+
mode = "TESTSTAND" if is_teststand() else "MANUAL"
74+
except ImportError:
75+
mode = "MANUAL"
76+
77+
print(f"XRP Hardware Test Runner — Mode: {mode}")
78+
print(f"Started at: {time.time()}")
79+
80+
all_passed = True
81+
for name, module_name in TEST_MODULES:
82+
if not run_module(name, module_name):
83+
all_passed = False
84+
85+
# Print summary
86+
print()
87+
print("=" * 50)
88+
print("SUMMARY")
89+
print("=" * 50)
90+
for mod in results["modules"]:
91+
status = "PASS" if mod["failed"] == 0 and mod["error"] is None else "FAIL"
92+
detail = f'{mod["passed"]} passed, {mod["failed"]} failed'
93+
if mod["error"]:
94+
detail += f' (ERROR: {mod["error"]})'
95+
print(f" [{status}] {mod['name']}: {detail}")
96+
97+
print()
98+
print(f"Total: {results['total_passed']} passed, {results['total_failed']} failed")
99+
100+
# Output machine-readable JSON on a tagged line for CI parsing
101+
print()
102+
print(f"__RESULTS_JSON__:{json.dumps(results)}")
103+
104+
# Exit with appropriate code
105+
if not all_passed:
106+
sys.exit(1)

0 commit comments

Comments
 (0)