Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions mote_bringup/test/sim_smoke/run_sim_smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
156 changes: 156 additions & 0 deletions mote_bringup/test/sim_smoke/verify_sim.py
Original file line number Diff line number Diff line change
@@ -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())
13 changes: 13 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 }
Loading