From debe7744e88296bc311812058a044aee482f9653 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:44:59 +0100 Subject: [PATCH] feat: Add pre-commit lints --- .pre-commit-config.yaml | 38 +++++ CLAUDE.md | 12 +- README.md | 23 +++ mote_bringup/test/sim_smoke/run_sim_smoke.sh | 66 ++++++++ mote_bringup/test/sim_smoke/verify_sim.py | 156 +++++++++++++++++++ pixi.toml | 13 ++ 6 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100755 mote_bringup/test/sim_smoke/run_sim_smoke.sh create mode 100755 mote_bringup/test/sim_smoke/verify_sim.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a77b85e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# Fast pre-commit checks. Run on commit after `pixi run lint-install`, or +# manually across the tree with `pixi run lint`. Hygiene fixes, shell linting, +# and Python error-checking only. +# Skip submodules and the binary/CAD/image assets (text hooks must not rewrite +# ASCII STEP files). +exclude: | + (?x)^( + third_party/| + design/| + docs/images/ + ) + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-case-conflict + - id: mixed-line-ending + args: [--fix=lf] + - id: check-shebang-scripts-are-executable + - id: check-added-large-files + args: [--maxkb=2048] # above pixi.lock (~1.7 MB) + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.18 + hooks: + - id: ruff-check + - id: ruff-format diff --git a/CLAUDE.md b/CLAUDE.md index 02a93ce..502f029 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,11 +24,17 @@ pixi run clean # Kill stale ROS processes and reset daemon pixi run rviz # RViz2 with mote config # Sim environment only (gz-sim Harmonic + ros_gz + gz_ros2_control; own solve, -# never affects the robot/Pi env) -pixi run -e sim sim # Headless Gazebo sim: world + robot + controllers -# Run slam/nav against it with use_sim_time, e.g.: +# never affects the robot/Pi env). The sim/sim-test tasks auto-select the sim +# environment (defined only there), so no `-e sim` is needed for them. +pixi run sim # Headless Gazebo sim: world + robot + controllers +pixi run sim-test # ~20 s headless smoke test (local pre-PR gate, needs a GPU) +# Ad-hoc (non-task) commands still need the env named: # pixi run -e sim -- ros2 launch mote_bringup slam_launch.py use_sim_time:=true pixi run test # colcon test for mote_hardware (gtest) + +# Lint environment only (pre-commit; minimal env, no ROS — auto-selected) +pixi run lint # run all pre-commit hooks across the tree (~1 s cached) +pixi run lint-install # wire pre-commit into .git/hooks (one time per clone) ``` Build artifacts go into `build/`, `install/`, and `log/` — all ignored by git. If you see CMakeCache.txt errors about a wrong source directory (e.g. from a path rename), delete the stale `build/` directory and rebuild. diff --git a/README.md b/README.md index d71a46e..4d9c702 100644 --- a/README.md +++ b/README.md @@ -171,12 +171,20 @@ robot install stays lean: ```bash pixi run sim # headless gz + robot + controllers +pixi run sim-test # ~20 s headless smoke test (drive + odom + scan + map) pixi run teleop # drive it around # Ad-hoc commands need the sim environment named explicitly: pixi run -e sim -- ros2 launch mote_bringup slam_launch.py use_sim_time:=true pixi run -e sim -- gz sim -g # optional: attach the Gazebo GUI ``` +`sim-test` is a fast end-to-end check: it brings up the sim and SLAM, drives the +robot, and asserts odometry integrates the motion, the lidar publishes sane +scans, and slam_toolbox produces a map. It needs a working render backend +(a GPU or fast software GL), so it's a local pre-PR gate rather than a +hosted-CI job — see the comment in +[`run_sim_smoke.sh`](mote_bringup/test/sim_smoke/run_sim_smoke.sh). + The world (`mote_bringup/worlds/mote_world.sdf`) is a simple walled room with a few obstacles. The simulated lidar uses RPLIDAR C1 datasheet values from [`robot.yaml`](mote_description/config/robot.yaml). @@ -197,6 +205,12 @@ pixi run sync # one-shot push pixi run sync-watch # keep pushing on every save (needs the dev env) ``` +For pushing a finished build to one or more robots, the direction is to publish +the first-party packages to the `prefix.dev/mote` channel (built with +[`pixi-build-ros`](https://pixi.prefix.dev/latest/build/ros/)) so a robot just +needs `pixi install` — no source checkout or compile on the bot. That work is in +progress. + ## SO-101 Follower Arm ![Mote with SO-101 arm](docs/images/mote_SO_101.webp) @@ -215,6 +229,15 @@ This project is still in its early stages and I'm happy to accept contributions of any kind. AI _aided_ contributions are also welcome but only if you can explain and vouch for every change! +A [pre-commit](https://pre-commit.com/) config handles quick hygiene checks, +shell linting (shellcheck) and Python error checking (ruff). Enable it once per +clone, and it runs automatically on commit: + +```bash +pixi run lint-install # wire it into .git/hooks (one time) +pixi run lint # or run across the whole tree manually (~1 s) +``` + ## Sponsorship If you want to help me test new sensors or components to lower the cost even diff --git a/mote_bringup/test/sim_smoke/run_sim_smoke.sh b/mote_bringup/test/sim_smoke/run_sim_smoke.sh new file mode 100755 index 0000000..c13d2a0 --- /dev/null +++ b/mote_bringup/test/sim_smoke/run_sim_smoke.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Headless end-to-end smoke test for the Mote Gazebo sim (~25 s on a workstation +# with a GPU; longer under software rendering). Brings up sim_launch.py + +# slam_launch.py, runs verify_sim.py, and tears everything down. +# +# Must run inside the 'sim' pixi environment, where gz, ros2 and the sim deps +# are on PATH: pixi run sim-test +# +# Exits 0 only if every stage passes; prints "FAIL: ..." and exits 1 otherwise. +# +# Needs a real GPU render backend; llvmpipe is too slow. Local pre-PR gate, +# not hosted CI. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="$SCRIPT_DIR/verify_sim.py" + +SIM_LOG="$(mktemp -t mote_sim_smoke_sim.XXXXXX.log)" +SLAM_LOG="$(mktemp -t mote_sim_smoke_slam.XXXXXX.log)" +SIM_PID="" +SLAM_PID="" + +cleanup() { + [ -n "$SIM_PID" ] && kill -- -"$SIM_PID" 2>/dev/null + [ -n "$SLAM_PID" ] && kill -- -"$SLAM_PID" 2>/dev/null + sleep 2 + # pkill matches the sim's processes, not this script + pkill -9 -f 'mote_world' 2>/dev/null + pkill -9 -f 'async_slam_toolbox_node' 2>/dev/null + ros2 daemon stop >/dev/null 2>&1 + true +} +trap cleanup EXIT + +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && tail -25 "$2"; exit 1; } + +# Start clean +ros2 daemon stop >/dev/null 2>&1 +sleep 1 + +echo ">> launching sim..." +setsid ros2 launch mote_bringup sim_launch.py > "$SIM_LOG" 2>&1 & +SIM_PID=$! +for _ in $(seq 90); do + grep -q "Configured and activated diff_drive_controller" "$SIM_LOG" && break + grep -q "Failed to load system plugin" "$SIM_LOG" && fail "gz_ros2_control plugin failed to load" "$SIM_LOG" + kill -0 "$SIM_PID" 2>/dev/null || fail "sim process exited early" "$SIM_LOG" + sleep 2 +done +grep -q "Configured and activated diff_drive_controller" "$SIM_LOG" \ + || fail "diff_drive_controller never activated" "$SIM_LOG" +echo "STEP1 OK: controllers active" + +echo ">> launching slam..." +setsid ros2 launch mote_bringup slam_launch.py use_sim_time:=true > "$SLAM_LOG" 2>&1 & +SLAM_PID=$! +for _ in $(seq 45); do + ros2 node list 2>/dev/null | grep -q slam_toolbox && break + sleep 2 +done +ros2 node list 2>/dev/null | grep -q slam_toolbox || fail "slam_toolbox never came up" "$SLAM_LOG" +echo "STEP2 OK: slam_toolbox up" + +echo ">> driving + verifying..." +timeout 120 python3 "$VERIFY" || fail "verify_sim.py assertions failed" +echo "SMOKE TEST PASS" diff --git a/mote_bringup/test/sim_smoke/verify_sim.py b/mote_bringup/test/sim_smoke/verify_sim.py new file mode 100755 index 0000000..1758c5f --- /dev/null +++ b/mote_bringup/test/sim_smoke/verify_sim.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Headless smoke test for the Mote Gazebo sim. + +Run by run_sim_smoke.sh once sim_launch.py + slam_launch.py are up. Drives the +robot and asserts the whole sim stack behaves: odometry integrates commanded +motion, the simulated lidar publishes sane scans, and slam_toolbox builds a map. + +Exits 0 on PASS, 1 on any failed assertion (printed as "FAIL: ..."). +""" + +import math +import sys +import time + +import rclpy +from rclpy.node import Node +from rclpy.qos import QoSDurabilityPolicy, QoSProfile, QoSReliabilityPolicy +from geometry_msgs.msg import TwistStamped +from nav_msgs.msg import Odometry, OccupancyGrid +from sensor_msgs.msg import LaserScan + + +class Verifier(Node): + def __init__(self): + super().__init__("sim_smoke_verifier") + self.set_parameters([rclpy.parameter.Parameter("use_sim_time", value=True)]) + self.odom = None + self.scans = [] + self.map = None + self.create_subscription( + Odometry, "/diff_drive_controller/odom", self.on_odom, 10 + ) + self.create_subscription(LaserScan, "/scan", self.on_scan, 10) + # slam_toolbox latches /map with transient-local reliable QoS + map_qos = QoSProfile( + depth=1, + reliability=QoSReliabilityPolicy.RELIABLE, + durability=QoSDurabilityPolicy.TRANSIENT_LOCAL, + ) + self.create_subscription(OccupancyGrid, "/map", self.on_map, map_qos) + self.cmd_pub = self.create_publisher( + TwistStamped, "/diff_drive_controller/cmd_vel", 10 + ) + + def on_odom(self, msg): + self.odom = msg + + def on_scan(self, msg): + self.scans.append((time.monotonic(), msg)) + + def on_map(self, msg): + self.map = msg + + def sim_now(self): + # node has use_sim_time=True, so this is /clock (sim) time in seconds + return self.get_clock().now().nanoseconds / 1e9 + + def drive(self, vx, wz, seconds): + # Gate on sim time, not wall time: the sim does not run at realtime + # (RTF varies with machine load), so a wall-clock duration would + # translate to a variable, unpredictable distance. wall_cap is a + # safety net so a stalled /clock can never hang the test. + start = self.sim_now() + wall_cap = time.monotonic() + seconds * 60 + 10 + while self.sim_now() - start < seconds: + msg = TwistStamped() + msg.header.stamp = self.get_clock().now().to_msg() + msg.twist.linear.x = vx + msg.twist.angular.z = wz + self.cmd_pub.publish(msg) + rclpy.spin_once(self, timeout_sec=0.05) + if time.monotonic() > wall_cap: + raise AssertionError("FAIL: sim clock not advancing (drive stalled)") + + def spin_for(self, seconds): + end = time.monotonic() + seconds + while time.monotonic() < end: + rclpy.spin_once(self, timeout_sec=0.1) + + def pose(self): + p = self.odom.pose.pose + yaw = 2.0 * math.atan2(p.orientation.z, p.orientation.w) + return p.position.x, p.position.y, yaw + + +def main(): + rclpy.init() + node = Verifier() + + # wait for odom + scan to start flowing + deadline = time.monotonic() + 40 + while (node.odom is None or not node.scans) and time.monotonic() < deadline: + rclpy.spin_once(node, timeout_sec=0.2) + assert node.odom is not None, "FAIL: no /diff_drive_controller/odom received" + assert node.scans, "FAIL: no /scan received" + + x0, y0, yaw0 = node.pose() + node.scans.clear() + + # drive forward 0.2 m/s for 3 s -> expect ~0.6 m forward + node.drive(0.2, 0.0, 3.0) + node.drive(0.0, 0.0, 0.5) + x1, y1, yaw1 = node.pose() + dist = math.hypot(x1 - x0, y1 - y0) + print(f"forward: moved {dist:.3f} m (expected ~0.6)") + assert 0.3 < dist < 0.9, f"FAIL: forward distance {dist:.3f} not in (0.3, 0.9)" + + # spin 1.0 rad/s for 2 s -> expect ~2 rad yaw change + node.drive(0.0, 1.0, 2.0) + node.drive(0.0, 0.0, 0.5) + _, _, yaw2 = node.pose() + dyaw = abs(math.atan2(math.sin(yaw2 - yaw1), math.cos(yaw2 - yaw1))) + print(f"spin: rotated {dyaw:.3f} rad (expected ~2.0, wrapped)") + assert 1.0 < dyaw, f"FAIL: yaw change {dyaw:.3f} too small" + + # scan rate + content over the drive window above. Rate is computed from + # message header stamps (sim time) so it reflects the configured sensor + # rate regardless of how fast the sim runs relative to wall time. + n = len(node.scans) + + def stamp_s(msg): + return msg.header.stamp.sec + msg.header.stamp.nanosec / 1e9 + + span = stamp_s(node.scans[-1][1]) - stamp_s(node.scans[0][1]) + rate = (n - 1) / span if span > 0 else 0.0 + scan = node.scans[-1][1] + finite = [r for r in scan.ranges if scan.range_min < r < scan.range_max] + print( + f"scan: {n} msgs, {rate:.1f} Hz, {len(finite)}/{len(scan.ranges)} finite ranges, " + f"min {min(finite):.2f} max {max(finite):.2f}" + ) + assert rate > 5.0, f"FAIL: scan rate {rate:.1f} Hz too low" + assert len(finite) > len(scan.ranges) * 0.5, "FAIL: too few finite ranges" + assert max(finite) < 12.0 and min(finite) > 0.05, "FAIL: ranges outside lidar spec" + + # slam_toolbox should have built a map by now; give it a few seconds to + # process the scans accumulated during the drive + deadline = time.monotonic() + 20 + while node.map is None and time.monotonic() < deadline: + node.spin_for(1.0) + assert node.map is not None, "FAIL: no /map published by slam_toolbox" + info = node.map.info + print(f"map: {info.width}x{info.height} @ {info.resolution:.3f} m/cell") + assert info.width > 0 and info.height > 0, "FAIL: empty map" + assert 0.01 < info.resolution < 0.5, ( + f"FAIL: map resolution {info.resolution} implausible" + ) + + print("PASS: sim smoke test") + node.destroy_node() + rclpy.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pixi.toml b/pixi.toml index 19c994f..1ec5da1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -84,12 +84,23 @@ sync-watch = "watchexec --debounce 1s -- pixi run sync" [feature.sim.tasks] sim = "ros2 launch mote_bringup sim_launch.py" +sim-test = { cmd = "bash mote_bringup/test/sim_smoke/run_sim_smoke.sh", depends-on = [ + "build", +] } [feature.sim.dependencies] ros-jazzy-ros-gz-sim = "*" ros-jazzy-ros-gz-bridge = "*" ros-jazzy-gz-ros2-control = "*" +[feature.lint.dependencies] +pre-commit = ">=4,<5" + +[feature.lint.tasks] +lint = "pre-commit run --all-files" +# One-time per clone: wire pre-commit into .git/hooks so it runs on commit +lint-install = "pre-commit install" + [activation] scripts = ["install/setup.sh"] env = { RMW_IMPLEMENTATION = "rmw_cyclonedds_cpp" } @@ -98,3 +109,5 @@ env = { RMW_IMPLEMENTATION = "rmw_cyclonedds_cpp" } dev = { features = ["dev"], solve-group = "default" } # Own solve (no solve-group) so sim deps can never shift the robot/Pi env sim = { features = ["sim"] } +# Minimal env (no ROS) so 'pixi run lint' is fast to solve and install +lint = { features = ["lint"], no-default-feature = true }