From a3b7f832237983ff6ecb418dcb564776613c82f8 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 13:09:58 +0100 Subject: [PATCH 01/17] Add committed sim smoke test (pixi run -e sim sim-test) Promotes the overnight verification scripts from .claude/tmp into the repo. run_sim_smoke.sh brings up sim_launch.py + slam_launch.py (use_sim_time), runs verify_sim.py, and tears everything down via a process-group trap. verify_sim.py drives the robot and asserts odom integration, scan rate/content, and that slam_toolbox publishes a map with plausible dimensions (the /map check is now a Python assertion with transient-local QoS rather than a shell echo). Runs in ~20 s on a workstation with a GPU. Location-independent and runs directly in the sim env (no nested pixi). Co-Authored-By: Claude Fable 5 --- mote_bringup/test/sim_smoke/run_sim_smoke.sh | 63 +++++++++ mote_bringup/test/sim_smoke/verify_sim.py | 132 +++++++++++++++++++ pixi.toml | 3 + 3 files changed, 198 insertions(+) create mode 100755 mote_bringup/test/sim_smoke/run_sim_smoke.sh create mode 100644 mote_bringup/test/sim_smoke/verify_sim.py 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..54584c1 --- /dev/null +++ b/mote_bringup/test/sim_smoke/run_sim_smoke.sh @@ -0,0 +1,63 @@ +#!/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 -e sim sim-test +# +# Exits 0 only if every stage passes; prints "FAIL: ..." and exits 1 otherwise. +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 + # belt-and-braces: these match the sim's own 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 100644 index 0000000..9c4c97c --- /dev/null +++ b/mote_bringup/test/sim_smoke/verify_sim.py @@ -0,0 +1,132 @@ +#!/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 drive(self, vx, wz, seconds): + end = time.monotonic() + seconds + while time.monotonic() < end: + 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) + + 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 ~6 s drive window above + n = len(node.scans) + span = node.scans[-1][0] - node.scans[0][0] + 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 3c1b184..c497f2e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -79,6 +79,9 @@ rviz = "ros2 launch mote_bringup rviz_launch.py" [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 = "*" From ac36a8d767af4419d46ac2115456f5ca9aa52a13 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 13:14:15 +0100 Subject: [PATCH 02/17] Make sim smoke test RTF-independent The drive phase gated on wall-clock time but asserted a sim-time distance. The sim does not run at realtime (scans arrive well above the configured rate), so on a loaded machine a wall-clock duration yields a variable distance and could fail the assertion spuriously. Gate the drive on sim time (with a wall-clock safety cap so a stalled /clock can't hang), and compute scan rate from message header stamps. Forward distance now lands at 0.600 m exactly. Co-Authored-By: Claude Fable 5 --- mote_bringup/test/sim_smoke/verify_sim.py | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/mote_bringup/test/sim_smoke/verify_sim.py b/mote_bringup/test/sim_smoke/verify_sim.py index 9c4c97c..c93b17d 100644 --- a/mote_bringup/test/sim_smoke/verify_sim.py +++ b/mote_bringup/test/sim_smoke/verify_sim.py @@ -48,15 +48,26 @@ def on_scan(self, 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): - end = time.monotonic() + seconds - while time.monotonic() < end: + # 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 @@ -99,9 +110,15 @@ def main(): 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 ~6 s drive window above + # 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) - span = node.scans[-1][0] - node.scans[0][0] + + 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] From b76f4ae513f4974e89477789b06afa1c646795a1 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 13:14:36 +0100 Subject: [PATCH 03/17] Document sim-test as a local pre-PR gate (not hosted CI) Record why the sensor+SLAM smoke test can't run on GPU-less hosted runners: software rendering starves the gz loop and the in-process controller_manager can't spawn controllers in time. Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++++++ mote_bringup/test/sim_smoke/run_sim_smoke.sh | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 8a45a97..ac785ea 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,16 @@ pixi run -e sim sim # headless gz + 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 pixi run teleop # drive it around +pixi run -e sim sim-test # ~20 s headless smoke test (drive + odom + scan + map) ``` +`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). diff --git a/mote_bringup/test/sim_smoke/run_sim_smoke.sh b/mote_bringup/test/sim_smoke/run_sim_smoke.sh index 54584c1..158fc93 100755 --- a/mote_bringup/test/sim_smoke/run_sim_smoke.sh +++ b/mote_bringup/test/sim_smoke/run_sim_smoke.sh @@ -7,6 +7,12 @@ # are on PATH: pixi run -e sim sim-test # # Exits 0 only if every stage passes; prints "FAIL: ..." and exits 1 otherwise. +# +# Needs a real render backend. On a GPU-less GitHub-hosted runner, llvmpipe +# software rendering is too slow: rasterising the gpu_lidar starves the gz +# process, so gz_ros2_control's in-process controller_manager can't service +# the controller spawners in time and they die. This is therefore a local +# pre-PR gate, not a hosted-CI job. A GPU/self-hosted runner could run it. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From 11e56fccb32d8e15a5f93a3ef00fd016368d6843 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 13:18:54 +0100 Subject: [PATCH 04/17] Drop redundant -e sim from sim task docs The sim and sim-test tasks are defined only in the sim feature, so 'pixi run sim' / 'pixi run sim-test' auto-select that environment (same as 'pixi run rviz' for the dev env). Only ad-hoc 'pixi run -- ' invocations still need -e sim, since those default to the robot env. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +++++--- README.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02a93ce..d921399 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,11 @@ 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) ``` diff --git a/README.md b/README.md index ac785ea..f57236d 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,18 @@ unmodified. The sim dependencies live in a separate pixi environment so the robot install stays lean: ```bash -pixi run -e sim sim # headless gz + robot + controllers +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 -pixi run teleop # drive it around -pixi run -e sim sim-test # ~20 s headless smoke test (drive + odom + scan + map) ``` +The `sim` and `sim-test` tasks select the sim environment automatically (they're +defined only there); the bare `pixi run -- …` form defaults to the robot +environment, so those need `-e sim`. + `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 From 2a64380f98c05eb07f860be77e27d1c861240132 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 13:26:12 +0100 Subject: [PATCH 05/17] Add pre-commit config with fast hygiene/lint hooks A quick pre-commit setup (~1 s cached): whitespace/EOF/line-ending fixers, YAML/TOML validation, merge-conflict and large-file guards, shellcheck for the shell scripts, and ruff (pyflakes only, no style reformatting) for the Python launch files. Slow build-dependent checks stay in CI / 'pixi run test' / 'pixi run sim-test'. Runs in its own minimal 'lint' pixi environment (no ROS) so it solves and installs fast. Tasks: 'pixi run lint' and 'pixi run lint-install'. design/ and docs/images/ are excluded so text hooks never rewrite the ASCII STEP CAD files or binary assets. verify_sim.py marked executable to satisfy the shebang check. Co-Authored-By: Claude Fable 5 --- .pre-commit-config.yaml | 41 +++ CLAUDE.md | 4 + README.md | 9 + mote_bringup/test/sim_smoke/verify_sim.py | 0 pixi.lock | 352 ++++++++++++++++++++++ pixi.toml | 10 + 6 files changed, 416 insertions(+) create mode 100644 .pre-commit-config.yaml mode change 100644 => 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..be912af --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +# Fast pre-commit checks. Run on commit after `pixi run lint-install`, or +# manually across the tree with `pixi run lint`. Kept deliberately quick: +# hygiene fixes, shell linting, and Python error-checking only — no slow +# builds or tests (those live in CI / `pixi run test` / `pixi run sim-test`). +# Skip submodules and the binary/CAD/image assets (text hooks must not rewrite +# ASCII STEP files or churn research notes). +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) and the CAD files + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + # Pyflakes only (real errors: undefined names, unused imports) — no style + # reformatting, so it won't churn the existing launch files. + - id: ruff + args: [--select=F, --fix] diff --git a/CLAUDE.md b/CLAUDE.md index d921399..502f029 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,10 @@ pixi run sim-test # ~20 s headless smoke test (local pre-PR gate, needs a # 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 f57236d..0e99b45 100644 --- a/README.md +++ b/README.md @@ -229,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/verify_sim.py b/mote_bringup/test/sim_smoke/verify_sim.py old mode 100644 new mode 100755 diff --git a/pixi.lock b/pixi.lock index 4a96cb4..0e2a973 100644 --- a/pixi.lock +++ b/pixi.lock @@ -3544,6 +3544,95 @@ environments: - conda: https://conda.anaconda.org/robostack-jazzy/linux-aarch64/ros-jazzy-zstd-vendor-0.26.9-np2py312h61f2ce4_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/linux-aarch64/ros2-distro-mutex-0.14.0-jazzy_16.conda - conda: https://prefix.dev/mote/linux-aarch64/scservo-linux-1.0.0-he8cfe8b_0.conda + lint: + channels: + - url: https://prefix.dev/mote/ + - url: https://conda.anaconda.org/robostack-jazzy/ + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py314h9891dd4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py314h0bd77cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.3-hcab7f73_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.1-hfae3067_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.2-h10b116e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42.1-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.3-h546c87b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.6-hc679e19_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pyyaml-6.0.3-py314h807365f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.1.0-py314hd7d8586_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda sim: channels: - url: https://prefix.dev/mote/ @@ -5926,6 +6015,20 @@ packages: license_family: MIT size: 295716 timestamp: 1761202958833 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + sha256: c6339858a0aaf5d939e00d345c98b99e4558f285942b27232ac098ad17ac7f8e + md5: cf45f4278afd6f4e6d03eda0f435d527 + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 300271 + timestamp: 1761203085220 - conda: https://conda.anaconda.org/conda-forge/linux-64/cfitsio-4.6.3-ha0b56bc_0.conda sha256: 31beae4b063f5e39ade020d3ee1ade0fdc8d5556fafac4d004ebe74ffbad44dd md5: 0fb449e4da07934a40db021c84773ed0 @@ -9422,6 +9525,16 @@ packages: license_family: LGPL size: 287897 timestamp: 1779091106723 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + size: 92400 + timestamp: 1769482286018 - conda: https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.9.3-nompi_hbf2fc22_104.conda sha256: cae2f8fe5258fc1a1d2b61cbc9190ed2c0a1b7cdf5d4aac98da071ade6dac152 md5: a2956b63b1851e9d5eb9f882d02fa3a9 @@ -11598,6 +11711,33 @@ packages: license: Python-2.0 size: 31608571 timestamp: 1772730708989 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + build_number: 100 + sha256: 6d28ac2b061179deb434d3d57afa98ffd20ec3c5d44ab8048a1ca33424b22d38 + md5: 0b9b2f83b5b600e1ac38becde8d0dd44 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 36717183 + timestamp: 1781255094700 + python_site_packages_path: lib/python3.14/site-packages - conda: https://conda.anaconda.org/conda-forge/linux-64/python-orocos-kdl-1.5.3-py312h1289d80_2.conda sha256: 481d724d6f7bbfa931f6f33a7940d3637b7289139526a5952d2289c114c5f5b6 md5: 30d805f6312812a6abef0933e56deefa @@ -11627,6 +11767,19 @@ packages: license_family: MIT size: 198293 timestamp: 1770223620706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda + sha256: b318fb070c7a1f89980ef124b80a0b5ccf3928143708a85e0053cde0169c699d + md5: 2035f68f96be30dc60a5dfd7452c7941 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + size: 202391 + timestamp: 1770223462836 - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda sha256: 776363493bad83308ba30bcb88c2552632581b143e8ee25b1982c8c743e73abc md5: 353823361b1d27eb3960efb076dfcaf6 @@ -12218,6 +12371,20 @@ packages: license_family: BSD size: 71525 timestamp: 1776960300375 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py314h9891dd4_0.conda + sha256: c84034056dc938c853e4f61e72e5bd37e2ec91927a661fb9762f678cbea52d43 + md5: 5d3c008e54c7f49592fca9c32896a76f + depends: + - __glibc >=2.17,<3.0.a0 + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 15004 + timestamp: 1769438727085 - conda: https://conda.anaconda.org/conda-forge/linux-64/uncrustify-0.83.0-h54a6638_0.conda sha256: 556ea427c375c2b9eb3122b19a1ce0891d535ad51a196f25c73786bb1d7c5156 md5: 29f7fdf10a9604e18924eebbde71ce62 @@ -13622,6 +13789,19 @@ packages: license_family: MIT size: 315113 timestamp: 1761203960926 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py314h0bd77cf_1.conda + sha256: 728e55b32bf538e792010308fbe55d26d02903ddc295fbe101167903a123dd6f + md5: f333c475896dbc8b15efd8f7c61154c7 + depends: + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 318357 + timestamp: 1761203973223 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cfitsio-4.6.3-hf475483_0.conda sha256: dca79e594c6326751291e1c0b47a92ef0bd2c0ad067105b0971fec2c73d3cf72 md5: fcc37a4d1fde95e2106059dd7ad3ec13 @@ -16979,6 +17159,15 @@ packages: license_family: LGPL size: 296558 timestamp: 1779091126550 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + sha256: 57c0dd12d506e84541c4e877898bd2a59cca141df493d34036f18b2751e0a453 + md5: 7b9813e885482e3ccb1fa212b86d7fd0 + depends: + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + size: 114056 + timestamp: 1769482343003 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnetcdf-4.9.3-nompi_h06de00c_104.conda sha256: bf4d494f37744f27c474cfcaafe65e8680c117137ed45fc4866dfe82ad9aa4dc md5: 76ed52bab2a733e0eb5f08b8ec3da215 @@ -18990,6 +19179,32 @@ packages: license: Python-2.0 size: 13757191 timestamp: 1772728951853 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.6-hc679e19_100_cp314.conda + build_number: 100 + sha256: dd56fd95db3cb49a69fbe41df80afc8bd5214daa829bcd3930de80f0408ba5eb + md5: 416c74941d13d9f2b9e68b1a900f7f50 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 34900936 + timestamp: 1781254861576 + python_site_packages_path: lib/python3.14/site-packages - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-orocos-kdl-1.5.3-py312h1ab2c47_2.conda sha256: 6a5a07185a4509ab13dcef98594d041081dd70d26b23651f4c86cc289f6217f6 md5: 574358b8f203748e7c46ff70a908ee76 @@ -19019,6 +19234,19 @@ packages: license_family: MIT size: 192182 timestamp: 1770223431156 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pyyaml-6.0.3-py314h807365f_1.conda + sha256: 496b5e65dfdd0aaaaa5de0dcaaf3bceea00fcb4398acf152f89e567c82ec1046 + md5: 9ae2c92975118058bd720e9ba2bb7c58 + depends: + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + size: 195678 + timestamp: 1770223441816 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/qhull-2020.2-h70be974_5.conda sha256: 49f777bdf3c5e030a8c7b24c58cdfe9486b51d6ae0001841079a3228bdf9fb51 md5: bb138086d938e2b64f5f364945793ebf @@ -19559,6 +19787,20 @@ packages: license_family: BSD size: 73779 timestamp: 1776960577002 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.1.0-py314hd7d8586_0.conda + sha256: 0a7efe469d7e2a34a1d017bc51cf6fb9a51436730256983694c5ad74a33bd4e0 + md5: f3a967efabf9cf450b7741487318ea6e + depends: + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 15756 + timestamp: 1769438772414 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/uncrustify-0.83.0-h7ac5ae9_0.conda sha256: e2b3719965204cc8c0fecaa685c96b527da168f97eb13a7e36fc7d8a6876c2b0 md5: 6a3d6acb5c7d4c203a33b14b8925c6c7 @@ -20342,6 +20584,15 @@ packages: license_family: BSD size: 54106 timestamp: 1757558592553 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13589 + timestamp: 1763607964133 - conda: https://conda.anaconda.org/conda-forge/noarch/colcon-argcomplete-0.3.3-pyhd8ed1ab_1.conda sha256: 05ccb85cad9ca58be9dcb74225f6180a68907a6ab0c990e3940f4decc5bb2280 md5: bde6042a1b40a2d4021e1becbe8dd84f @@ -20641,6 +20892,16 @@ packages: license_family: APACHE size: 303675 timestamp: 1780988738861 +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e + md5: 58638f77697c4f6726753eb8be34818b + depends: + - python >=3.10 + - python + license: Apache-2.0 + license_family: APACHE + size: 303705 + timestamp: 1781320269259 - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda sha256: 5603c7d0321963bb9b4030eadabc3fd7ca6103a38475b4e0ed13ed6d97c86f4e md5: 0a2014fd9860f8b1eaa0b1f3d3771a08 @@ -20838,6 +21099,16 @@ packages: license_family: MIT size: 73563 timestamp: 1733928021866 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 + md5: 84a3233b709a289a4ddd7a2fd27dd988 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + size: 79757 + timestamp: 1776455344188 - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.15-pyhcf101f3_0.conda sha256: 3d25f9f6f7ab3e1ce6429fc8c8aae0335cf446692e715068488536d220cc43de md5: 1b9083b7f00609605d1483dbc6071a81 @@ -21002,6 +21273,16 @@ packages: license_family: Apache size: 15851 timestamp: 1749895533014 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + size: 40866 + timestamp: 1766261270149 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 md5: 4c06a92e74452cfa53623a81592e8934 @@ -21022,6 +21303,16 @@ packages: license_family: MIT size: 19044 timestamp: 1667916747996 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d + md5: 2c5ef45db85d34799771629bd5860fd7 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 26308 + timestamp: 1779972894916 - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e md5: d7585b6550ad04c8c5e21097ada2888e @@ -21048,6 +21339,20 @@ packages: license_family: OTHER size: 2348171 timestamp: 1675353652214 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + sha256: 716960bf0a9eb334458a26b3bdcb17b8d0786062138a4f48c7f335c8418c5d0b + md5: 7859736b4f8ebe6c8481bf48d91c9a1e + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + size: 201606 + timestamp: 1776858157327 - conda: https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.3-pyhfe8187e_0.conda sha256: 71a9524f44d6ac6304feae71e2bbe8d8ce0816f0be7a0271c15681ad1040965d md5: e0f4549ccb507d4af8ed5c5345210673 @@ -21238,6 +21543,18 @@ packages: license_family: APACHE size: 233310 timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 + md5: 7a9095c9300d1b50b1785ca9bc4cadae + depends: + - python >=3.10 + - filelock >=3.15.4 + - platformdirs <5,>=4.3.6 + - python + license: MIT + license_family: MIT + size: 35514 + timestamp: 1781257630962 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda build_number: 8 sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 @@ -21248,6 +21565,16 @@ packages: license_family: BSD size: 6958 timestamp: 1752805918820 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 6989 + timestamp: 1752805904792 - conda: https://conda.anaconda.org/conda-forge/noarch/rosdistro-1.0.1-pyhd8ed1ab_0.conda sha256: bff3b2fe7afe35125669ffcb7d6153db78070a753e1e4ac3b3d8d198eb6d6982 md5: b7ed380a9088b543e06a4f73985ed03a @@ -21282,6 +21609,15 @@ packages: license_family: MIT size: 787541 timestamp: 1745484086827 +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 + md5: 8e194e7b992f99a5015edbd4ebd38efd + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 639697 + timestamp: 1773074868565 - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d md5: 3339e3b65d58accf4ca4fb8748ab16b3 @@ -21381,6 +21717,22 @@ packages: license: LicenseRef-Public-Domain size: 119135 timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.3-pyhcf101f3_0.conda + sha256: 72ac3da92b6417ad9667009b881ee75e0ca06ef5850f8781df54b27bd6f8be3d + md5: 84270b1d45569be1f9d2c8fe674993a4 + depends: + - python >=3.10 + - distlib >=0.3.7,<1 + - filelock <4,>=3.24.2 + - importlib-metadata >=6.6 + - platformdirs >=3.9.1,<5 + - python-discovery >=1.4.2 + - typing_extensions >=4.13.2 + - python + license: MIT + license_family: MIT + size: 5176090 + timestamp: 1781267575706 - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda sha256: 9ab2c12053ea8984228dd573114ffc6d63df42c501d59fda3bf3aeb1eaa1d23e md5: 7da1571f560d4ba3343f7f4c48a79c76 diff --git a/pixi.toml b/pixi.toml index c497f2e..88ca229 100644 --- a/pixi.toml +++ b/pixi.toml @@ -88,6 +88,14 @@ 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" } @@ -96,3 +104,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 } From bb2ce344a44300145d2e842cb40f214e71e4f195 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 13 Jun 2026 23:59:25 +0100 Subject: [PATCH 06/17] Add sync-watch task and document the fast inner-loop Track A of the dev-story plan. watchexec (dev feature) continuously rsyncs to the Pi on save via 'pixi run sync-watch'. README now spells out the colcon --symlink-install fast path: edits to launch/config/ Python go live with no rebuild, so the loop is sync-watch + re-launch, not sync + rebuild. Replaces the stale 'pixi pack TBD' note with the pixi-build-ros channel direction. Co-Authored-By: Claude Fable 5 --- README.md | 18 +++++++++++++++--- pixi.lock | 25 +++++++++++++++++++++++++ pixi.toml | 5 +++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e99b45..77d76a0 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,23 @@ For now, I currently develop on a workstation and push to the Pi with rsync. The [`pixi.toml`](pixi.toml) `[tasks]` `sync` entry to match your Pi, then: ```bash -pixi run sync +pixi run sync # one-shot push +pixi run sync-watch # keep pushing on every save (needs the dev env) ``` -I want to try [pixi pack](https://pixi.prefix.dev/latest/deployment/pixi_pack/) -for this eventually but haven't had a chance yet. +**Fast inner loop:** the build uses `colcon build --symlink-install`, so once +you've built once on the Pi, edits to existing launch files, `robot.yaml`, +controller params, or Python launch logic take effect the next time you launch — +**no rebuild needed**. Only changes to the `mote_hardware` C++ (or brand-new +files that need installing) require another `pixi run build`. So the usual loop +is: `sync-watch` running in one terminal, edit on the laptop, re-run `pixi run +launch` on the Pi. + +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 diff --git a/pixi.lock b/pixi.lock index 0e2a973..ae7b0aa 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1908,6 +1908,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-9.5.2-py312h244374b_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-base-9.5.2-py312h6fba518_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/vtk-io-ffmpeg-9.5.2-py312hcdbd8b1_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/watchexec-2.5.1-hb17b654_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-2.2.1-py312h4c3975b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 @@ -2952,6 +2953,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/vtk-9.5.2-py312h4954c87_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/vtk-base-9.5.2-py312h90a26f6_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/vtk-io-ffmpeg-9.5.2-py312haba1314_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/watchexec-2.5.1-h069e38c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.25.0-h4f8a99f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wrapt-2.2.1-py312hcd1a082_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x264-1!164.3095-h4e544f5_2.tar.bz2 @@ -12562,6 +12564,18 @@ packages: license_family: BSD size: 108379 timestamp: 1768718074243 +- conda: https://conda.anaconda.org/conda-forge/linux-64/watchexec-2.5.1-hb17b654_0.conda + sha256: 5f2140f7b6a18138cad7a305b4185bea8499a4e044afb91e3fad72a130255d3b + md5: e5b552a8e3cc6084bce363770ae18c46 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: Apache-2.0 + license_family: APACHE + size: 3311519 + timestamp: 1774855010015 - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda sha256: ea374d57a8fcda281a0a89af0ee49a2c2e99cc4ac97cf2e2db7064e74e764bdb md5: 996583ea9c796e5b915f7d7580b51ea6 @@ -19973,6 +19987,17 @@ packages: license_family: BSD size: 106747 timestamp: 1768718080474 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/watchexec-2.5.1-h069e38c_0.conda + sha256: 597470409a6cb80d85a2c433d30b91fbc542ccb8096a3aadcf084955e5a883ac + md5: 7e089d2d3ac6cc22b9ee91563116a028 + depends: + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: Apache-2.0 + license_family: APACHE + size: 3164356 + timestamp: 1774855038268 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.25.0-h4f8a99f_0.conda sha256: 3cc479df517b0ce110835a1256f91ca568581cb6dfe1c53a0786f0a226039a45 md5: 0a7a9548726f98d5869fd4c43e110f0f diff --git a/pixi.toml b/pixi.toml index 88ca229..1ec5da1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -73,9 +73,14 @@ ros-jazzy-ament-cmake-gtest = ">=2.5" [feature.dev.dependencies] ros-jazzy-desktop = ">=0.11.0" ros-jazzy-nav2-bringup = ">=1.3.11,<2" +watchexec = ">=2.5,<3" [feature.dev.tasks] rviz = "ros2 launch mote_bringup rviz_launch.py" +# Continuously rsync to the Pi on save (watchexec honours .gitignore, so +# build/install/log/.pixi are skipped). Edits to launch/config/Python go live +# without a rebuild thanks to colcon --symlink-install; only C++ needs `build`. +sync-watch = "watchexec --debounce 1s -- pixi run sync" [feature.sim.tasks] sim = "ros2 launch mote_bringup sim_launch.py" From 432794c396c9ac15c0c272e88334c36fa23388b4 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sun, 14 Jun 2026 00:25:04 +0100 Subject: [PATCH 07/17] Add wiring and assembly/print guides design/WIRING.md: connection tables (power + data), a GitHub-rendered Mermaid topology diagram, the 5V power analysis (servos run below their 7.4V rating; USB-C PD delivers far less than its rated wattage at 5V, which is why the servo board needs the high-current Out1 port), a rough 5V budget, and the BNO085 I2C pinout. design/ASSEMBLY.md: per-part print settings and step-by-step assembly grounded in the 235mm chassis / 50mm standoff / power-bank-sandwich / ORP-grid design. Both linked from design/README.md. Hardware-specific unknowns (connector genders, servo stall current at 5V, exact screw lengths, tyre material) are flagged as Verify rather than guessed. Co-Authored-By: Claude Fable 5 --- design/ASSEMBLY.md | 77 ++++++++++++++++++++++++++++++ design/README.md | 5 ++ design/WIRING.md | 114 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 design/ASSEMBLY.md create mode 100644 design/WIRING.md diff --git a/design/ASSEMBLY.md b/design/ASSEMBLY.md new file mode 100644 index 0000000..9ce160f --- /dev/null +++ b/design/ASSEMBLY.md @@ -0,0 +1,77 @@ +# Printing & Assembly + +How to print the parts and put Mote together. Wiring is covered separately in +[WIRING.md](WIRING.md); the part files live in +[`step/`](step/) (editable in CAD), [`stl/`](stl/) (mesh) and +[`3mf/`](3mf/) (mesh + print setup). + +> ⚠️ Print settings below are sensible starting points, not measured-optimal — +> confirm against your printer/filament. The `.3mf` files carry their own plate +> setup; prefer them if your slicer reads 3mf. + +## Printing + +All parts fit a 256 mm bed (the chassis is 235 mm — see +[design rationale](README.md#chassis-diameter-235mm)). + +Suggested defaults: **PLA**, 0.2 mm layer height, 3 walls, 15–20% infill, brim on +the large flat plates to prevent corner lift. + +| Part (qty) | Material | Orientation | Supports | +| --- | --- | --- | --- | +| Chassis Base (1) | PLA | flat | none | +| Chassis Top (1) | PLA | flat | none | +| Motor Support (1–2) | PLA | as exported | likely none | +| Pi Bottom + Pi Top (1 each) | PLA | flat | none | +| Waveshare Mount (1) | PLA | flat | none | +| C1 Lidar Mount (1) | PLA | flat | check overhangs | +| Camera Mount (1) | PLA | flat | check overhangs | +| Battery Mount (1) | PLA | flat | none | +| Wheel Inner (2) | PLA | hub down | none | +| Wheel Tyre (2) | **TPU** (grip) | flat | none | +| Caster (1) | PLA | — | — | +| SO Base ORP (1, optional) | PLA | flat | none | + +Notes: +- **Wheels are two-part**: a rigid `Wheel Inner` hub plus a `Wheel Tyre` — + printing the tyre in TPU gives traction. ⚠️ Confirm the intended tyre material. +- **Caster is unresolved** — printed and several off-the-shelf options have all + been unsatisfactory so far; treat this part as provisional. A bought ball + caster of the right height may be preferable (see Assembly step 6). +- Drive wheels and hubs are intentionally printed (removed from the + [BOM](BOM.md) for that reason). + +## Assembly + +**Hardware:** the M3 button-head hex set from the [BOM](BOM.md) (screws, nuts, +washers) covers all fasteners. Standoffs are buffered to **50 mm** (set by servo +height 45.2 mm / lidar 41.3 mm). ⚠️ Exact screw lengths and standoff count per +joint aren't captured here yet — fill in as you build. + +Suggested order: + +1. **Drive train.** Mount each STS3215 to the `Motor Support`, then press a + `Wheel Inner` onto the servo horn and fit the `Wheel Tyre` over it. Wheels are + centred and inset so the footprint stays circular. Left servo is **ID 7**, + right is **ID 9** (assign with `pixi run setup-ids` — see the + [README](../README.md#4-configure-the-servos)). +2. **Lower plate.** Fasten the `Motor Support`/servos and the `Battery Mount` to + the `Chassis Base`. +3. **Power bank sandwich.** Seat the power bank in the `Battery Mount` between the + plates to keep the centre of mass low, then stand the **50 mm** standoffs up + from the base. +4. **Electronics deck.** Mount the Pi in the `Pi Bottom`/`Pi Top` holder and the + servo board on the `Waveshare Mount`, then attach both to the `Chassis Top`. +5. **Close it up.** Fix the `Chassis Top` onto the standoffs. +6. **Caster.** Fit the front `Caster` to the underside of the base for the third + contact point (provisional — see Printing notes). +7. **Sensors.** Mount the `C1 Lidar Mount` and `Camera Mount` to the sensor slots + — these follow the ORP **3.5 mm / 20 mm grid**, so they relocate on the grid. +8. **Wire it.** Follow [WIRING.md](WIRING.md): bank Out1 → servo board (barrel), + bank Out2 → Pi, servo board USB → Pi, lidar/camera USB → Pi. Route cables + through the standoff gap and keep the lidar's 360° view clear. +9. **(Optional) SO-101 arm.** The `SO Base ORP` adapter mounts the + [SO-101 follower arm](https://github.com/TheRobotStudio/SO-ARM100) on the ORP + grid. + +Then bring the software up per the [main README](../README.md#5-launch). diff --git a/design/README.md b/design/README.md index 50585d0..e5364c5 100644 --- a/design/README.md +++ b/design/README.md @@ -51,3 +51,8 @@ Out1 to stop them stalling. The Pi runs fine on the 45W port. ## Bill of Materials See [BOM.md](BOM.md). + +## Build guides + +- [WIRING.md](WIRING.md) — connections, power topology and budget, IMU pinout. +- [ASSEMBLY.md](ASSEMBLY.md) — print settings per part and assembly steps. diff --git a/design/WIRING.md b/design/WIRING.md new file mode 100644 index 0000000..cecb718 --- /dev/null +++ b/design/WIRING.md @@ -0,0 +1,114 @@ +# Wiring & Power + +How the Mote electronics connect. Everything runs from a single USB-C power +bank at **5 V** (see [the 5 V-only rationale](README.md#power-5v-only)). Device +names in the launch stack (`/dev/mote_*`) are created by the udev rules in +[`mote_bringup/udev/`](../mote_bringup/udev/). + +> ⚠️ Items marked **Verify** depend on the exact connectors on your units — +> confirm against your hardware before ordering cables. + +## Diagram + +```mermaid +flowchart LR + BANK["UGREEN 140W power bank"] + BANK -->|"Out1 high-current · USB-C to DC barrel · 5V"| MCB["Waveshare Serial Bus
Servo Driver Board"] + BANK -->|"Out2 · USB-C to USB-C · 5V"| PI["Raspberry Pi 5"] + + MCB ==>|"3-pin bus: 5V + serial"| SL["Left STS3215
ID 7, inverted"] + MCB ==>|"3-pin bus: 5V + serial"| SR["Right STS3215
ID 9"] + + MCB -->|"USB serial to /dev/mote_servos"| PI + LIDAR["RPLIDAR C1"] -->|"USB to /dev/mote_lidar"| PI + CAM["USB webcam"] -->|"USB to /dev/mote_camera"| PI + IMU["BNO085 IMU
in testing"] -.->|"I2C to GPIO"| PI + + classDef power fill:#cde,stroke:#369; + class BANK,MCB,PI power; +``` + +Solid lines = power and/or wired data. The lidar, camera and IMU are powered by +the Pi over their data connection (USB / GPIO), not from the bank directly. + +## Connections + +### Power + +| From (port) | Cable | To | Carries | Notes | +| --- | --- | --- | --- | --- | +| Bank **Out1** (100 W USB-C) | USB-C → DC 5.5×2.1 mm barrel, **5 V** | Servo board DC input | 5 V power | Must be the higher-current 5 V port — servos brown out on Out2 (see Power notes) | +| Bank **Out2** (45 W USB-C) | USB-C ↔ USB-C, 0.3 m | Pi 5 USB-C power in | 5 V power | Pi negotiates 5 V/5 A | +| Servo board | 3-pin servo lead | Left STS3215 (**ID 7**) | 5 V + 1 Mbaud serial | Left wheel is mounted inverted (sign handled in firmware) | +| Servo board | 3-pin servo lead | Right STS3215 (**ID 9**) | 5 V + 1 Mbaud serial | Servos share the bus; can be daisy-chained | + +### Data + +| From | Cable | To | Device node | Notes | +| --- | --- | --- | --- | --- | +| Servo board (USB data) | USB-A ↔ USB-C, 0.3 m | Pi USB-A | `/dev/mote_servos` | CH343 USB-serial, 1 Mbaud. **Verify** board-side connector | +| RPLIDAR C1 | USB (incl. SLAMTEC adapter) | Pi USB-A | `/dev/mote_lidar` | 460800 baud; powered over USB. **Verify** cable supplied with unit | +| USB webcam | USB-A (captive) | Pi USB-A | `/dev/mote_camera` | UVC; powered over USB | +| BNO085 (testing) | 4× jumper to GPIO header | Pi GPIO | `/dev/i2c-1` | See IMU section | + +The Pi 5 has 4 USB ports (2× USB-3, 2× USB-2). Servo board, lidar and camera +take three of them; keep the lidar on its own controller if you see scan +dropouts under load. + +## Power notes (the fiddly bit) + +Three things make the 5 V budget non-obvious: + +1. **Servos are run below their rated voltage.** The STS3215's spec operating + range is **7.4–12.6 V**, but Mote feeds the servo board **5 V**. This is the + deliberate 5 V-only design tradeoff: it works in practice (the `velocity_scale` + in [`robot.yaml`](../mote_description/config/robot.yaml) is calibrated on real + hardware with `velocity_cal`), but you get less torque/speed headroom than the + datasheet figures, which are quoted at the higher voltage. If you ever find + the drive underpowered, this is why. + +2. **A "100 W" USB-C port is not 100 W at 5 V.** USB-C PD advertises its top + wattage at high voltage (≈20 V); at 5 V each port is limited by its 5 V + current profile — commonly 5 V/3 A (15 W), sometimes 5 V/5 A (25 W). That is + why the servo board must sit on **Out1**: under drive (and especially toward + stall) the two servos draw more 5 V current than the 45 W **Out2** port will + source, and they brown out. This matches the empirical note in + [README.md](README.md#power-bank-form-factor). + +3. **Confirm the barrel cable's profile.** The USB-C→DC cable must request 5 V at + enough current from Out1. **Verify** it negotiates 5 V (not 9/12 V, which + would over-volt the board) and sustains the servo load on your bank. + +### Rough 5 V budget + +| Load | Rail | Typical | Peak | Fed from | +| --- | --- | --- | --- | --- | +| Raspberry Pi 5 | 5 V | 5–10 W | up to ~25 W (5 V/5 A) | Out2 | +| 2× STS3215 (driving) | 5 V via board | ~2–6 W | high near stall | Out1 | +| RPLIDAR C1 | 5 V via Pi USB | 1.15 W (230 mA) | — | Pi | +| USB webcam | 5 V via Pi USB | ~0.5–1 W | — | Pi | +| BNO085 IMU | 3.3 V via GPIO | <0.1 W | — | Pi | + +> ⚠️ Servo stall current at 5 V isn't published (datasheets quote 2.7 A at the +> rated voltage). Treat the servo column as the variable one and size headroom +> accordingly — measuring it on the bench is the only way to pin it down. + +## IMU (BNO085) — in testing + +I²C is the simplest of the BNO085's interfaces. Header pins must be soldered to +the breakout first (see [BOM](BOM.md)). + +| BNO085 pin | Pi 40-pin header | GPIO | +| --- | --- | --- | +| VIN | pin 1 (3V3) | — | +| GND | pin 6 (GND) | — | +| SDA | pin 3 | GPIO 2 | +| SCL | pin 5 | GPIO 3 | + +Enable I²C (`raspi-config` → Interfaces, or `dtparam=i2c_arm=on`); the BNO085 +appears at address `0x4A` (or `0x4B`) — check with `i2cdetect -y 1`. Intended use +is wheel-slip detection / odometry fusion via `robot_localization`. + +> ⚠️ **Verify** your breakout's logic/voltage: most BNO085 boards have an +> onboard regulator and accept 3–5 V on VIN, but confirm yours before wiring to +> 3V3 vs 5V. From 66ea4a8dafe884ca6e53980b1d498fb489644d28 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sun, 14 Jun 2026 00:26:05 +0100 Subject: [PATCH 08/17] Clarify servos run fine at 5V (confirmed on hardware) Reframe the servo-voltage note: 5V operation is confirmed working, not a performance worry. Keep only the factual caveat that datasheet torque figures are quoted at the higher rated voltage. Co-Authored-By: Claude Fable 5 --- design/WIRING.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/design/WIRING.md b/design/WIRING.md index cecb718..badeb48 100644 --- a/design/WIRING.md +++ b/design/WIRING.md @@ -59,13 +59,12 @@ dropouts under load. Three things make the 5 V budget non-obvious: -1. **Servos are run below their rated voltage.** The STS3215's spec operating - range is **7.4–12.6 V**, but Mote feeds the servo board **5 V**. This is the - deliberate 5 V-only design tradeoff: it works in practice (the `velocity_scale` - in [`robot.yaml`](../mote_description/config/robot.yaml) is calibrated on real - hardware with `velocity_cal`), but you get less torque/speed headroom than the - datasheet figures, which are quoted at the higher voltage. If you ever find - the drive underpowered, this is why. +1. **Servos run at 5 V and that's fine.** The STS3215's datasheet operating + range starts at **7.4 V**, but Mote feeds the servo board **5 V** and the + drive works fine in practice — this is confirmed on real hardware, with + `velocity_scale` in [`robot.yaml`](../mote_description/config/robot.yaml) + calibrated at 5 V via `velocity_cal`. Just don't expect the datasheet torque + numbers, since those are quoted at the higher voltage. 2. **A "100 W" USB-C port is not 100 W at 5 V.** USB-C PD advertises its top wattage at high voltage (≈20 V); at 5 V each port is limited by its 5 V From 3bcf35e7794df95103c3b4ee061062a3296901fa Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sun, 14 Jun 2026 00:30:41 +0100 Subject: [PATCH 09/17] Fill in confirmed hardware details (tyre, screws, connectors) Tyre is TPU (confirmed). Screws: most joints M3x12 (~6mm plate + ~6mm captive-nut pocket), M3x10 for thinner stacks. All data links are plain USB, so the connector-gender Verify flags are resolved. Remaining Verify items are electrical only (barrel-cable 5V profile, servo stall current, BNO085 VIN voltage). Co-Authored-By: Claude Fable 5 --- design/ASSEMBLY.md | 12 +++++++----- design/WIRING.md | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/design/ASSEMBLY.md b/design/ASSEMBLY.md index 9ce160f..eaa96ea 100644 --- a/design/ASSEMBLY.md +++ b/design/ASSEMBLY.md @@ -33,8 +33,8 @@ the large flat plates to prevent corner lift. | SO Base ORP (1, optional) | PLA | flat | none | Notes: -- **Wheels are two-part**: a rigid `Wheel Inner` hub plus a `Wheel Tyre` — - printing the tyre in TPU gives traction. ⚠️ Confirm the intended tyre material. +- **Wheels are two-part**: a rigid `Wheel Inner` hub plus a `Wheel Tyre` printed + in **TPU** for traction. - **Caster is unresolved** — printed and several off-the-shelf options have all been unsatisfactory so far; treat this part as provisional. A bought ball caster of the right height may be preferable (see Assembly step 6). @@ -44,9 +44,11 @@ Notes: ## Assembly **Hardware:** the M3 button-head hex set from the [BOM](BOM.md) (screws, nuts, -washers) covers all fasteners. Standoffs are buffered to **50 mm** (set by servo -height 45.2 mm / lidar 41.3 mm). ⚠️ Exact screw lengths and standoff count per -joint aren't captured here yet — fill in as you build. +washers) covers all fasteners — its M3×6–35 range means you can match each joint +exactly. Most joints take **M3×12**: the screw passes through a ~6 mm plate into +a part with a ~6 mm captive-nut pocket. Use **M3×10** for thinner stacks. +Standoffs are buffered to **50 mm** (set by servo height 45.2 mm / lidar +41.3 mm). Suggested order: diff --git a/design/WIRING.md b/design/WIRING.md index badeb48..3138062 100644 --- a/design/WIRING.md +++ b/design/WIRING.md @@ -5,8 +5,9 @@ bank at **5 V** (see [the 5 V-only rationale](README.md#power-5v-only)). Device names in the launch stack (`/dev/mote_*`) are created by the udev rules in [`mote_bringup/udev/`](../mote_bringup/udev/). -> ⚠️ Items marked **Verify** depend on the exact connectors on your units — -> confirm against your hardware before ordering cables. +Every data link is plain USB (standard male plugs into the device ports), so +there's no special connector wiring — just the cables in the [BOM](BOM.md). +A few electrical details still want bench confirmation; those are marked **Verify**. ## Diagram @@ -46,8 +47,8 @@ the Pi over their data connection (USB / GPIO), not from the bank directly. | From | Cable | To | Device node | Notes | | --- | --- | --- | --- | --- | -| Servo board (USB data) | USB-A ↔ USB-C, 0.3 m | Pi USB-A | `/dev/mote_servos` | CH343 USB-serial, 1 Mbaud. **Verify** board-side connector | -| RPLIDAR C1 | USB (incl. SLAMTEC adapter) | Pi USB-A | `/dev/mote_lidar` | 460800 baud; powered over USB. **Verify** cable supplied with unit | +| Servo board (USB-C data) | USB-A ↔ USB-C, 0.3 m | Pi USB-A | `/dev/mote_servos` | CH343 USB-serial, 1 Mbaud | +| RPLIDAR C1 | USB (cable supplied with unit) | Pi USB-A | `/dev/mote_lidar` | 460800 baud; powered over USB | | USB webcam | USB-A (captive) | Pi USB-A | `/dev/mote_camera` | UVC; powered over USB | | BNO085 (testing) | 4× jumper to GPIO header | Pi GPIO | `/dev/i2c-1` | See IMU section | From 93aeb471a738179b4d4b5beb551e2d806b7956e3 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 15:40:00 +0100 Subject: [PATCH 10/17] Clean up PR --- .pre-commit-config.yaml | 19 ++++++++----------- mote_bringup/test/sim_smoke/run_sim_smoke.sh | 11 ++++------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be912af..a77b85e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,8 @@ # Fast pre-commit checks. Run on commit after `pixi run lint-install`, or -# manually across the tree with `pixi run lint`. Kept deliberately quick: -# hygiene fixes, shell linting, and Python error-checking only — no slow -# builds or tests (those live in CI / `pixi run test` / `pixi run sim-test`). +# 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 or churn research notes). +# ASCII STEP files). exclude: | (?x)^( third_party/| @@ -25,17 +24,15 @@ repos: args: [--fix=lf] - id: check-shebang-scripts-are-executable - id: check-added-large-files - args: [--maxkb=2048] # above pixi.lock (~1.7 MB) and the CAD files + args: [--maxkb=2048] # above pixi.lock (~1.7 MB) - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + rev: v0.11.0.1 hooks: - id: shellcheck - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.15.18 hooks: - # Pyflakes only (real errors: undefined names, unused imports) — no style - # reformatting, so it won't churn the existing launch files. - - id: ruff - args: [--select=F, --fix] + - id: ruff-check + - id: ruff-format diff --git a/mote_bringup/test/sim_smoke/run_sim_smoke.sh b/mote_bringup/test/sim_smoke/run_sim_smoke.sh index 158fc93..c13d2a0 100755 --- a/mote_bringup/test/sim_smoke/run_sim_smoke.sh +++ b/mote_bringup/test/sim_smoke/run_sim_smoke.sh @@ -4,15 +4,12 @@ # 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 -e sim sim-test +# are on PATH: pixi run sim-test # # Exits 0 only if every stage passes; prints "FAIL: ..." and exits 1 otherwise. # -# Needs a real render backend. On a GPU-less GitHub-hosted runner, llvmpipe -# software rendering is too slow: rasterising the gpu_lidar starves the gz -# process, so gz_ros2_control's in-process controller_manager can't service -# the controller spawners in time and they die. This is therefore a local -# pre-PR gate, not a hosted-CI job. A GPU/self-hosted runner could run it. +# 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)" @@ -27,7 +24,7 @@ cleanup() { [ -n "$SIM_PID" ] && kill -- -"$SIM_PID" 2>/dev/null [ -n "$SLAM_PID" ] && kill -- -"$SLAM_PID" 2>/dev/null sleep 2 - # belt-and-braces: these match the sim's own processes, not this script + # 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 From 6bf3d53c05b49ec33839f72018e2c26099089d10 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 16:46:37 +0100 Subject: [PATCH 11/17] Cleanup wiring and power doc --- design/ASSEMBLY.md | 29 ++++++++------- design/BOM.md | 2 +- design/WIRING.md | 92 +++++++++++++++------------------------------- 3 files changed, 45 insertions(+), 78 deletions(-) diff --git a/design/ASSEMBLY.md b/design/ASSEMBLY.md index eaa96ea..a777da0 100644 --- a/design/ASSEMBLY.md +++ b/design/ASSEMBLY.md @@ -17,22 +17,23 @@ All parts fit a 256 mm bed (the chassis is 235 mm — see Suggested defaults: **PLA**, 0.2 mm layer height, 3 walls, 15–20% infill, brim on the large flat plates to prevent corner lift. -| Part (qty) | Material | Orientation | Supports | -| --- | --- | --- | --- | -| Chassis Base (1) | PLA | flat | none | -| Chassis Top (1) | PLA | flat | none | -| Motor Support (1–2) | PLA | as exported | likely none | -| Pi Bottom + Pi Top (1 each) | PLA | flat | none | -| Waveshare Mount (1) | PLA | flat | none | -| C1 Lidar Mount (1) | PLA | flat | check overhangs | -| Camera Mount (1) | PLA | flat | check overhangs | -| Battery Mount (1) | PLA | flat | none | -| Wheel Inner (2) | PLA | hub down | none | -| Wheel Tyre (2) | **TPU** (grip) | flat | none | -| Caster (1) | PLA | — | — | -| SO Base ORP (1, optional) | PLA | flat | none | +| Part (qty) | Material | Orientation | Supports | +| --------------------------- | -------------- | ----------- | --------------- | +| Chassis Base (1) | PLA | flat | none | +| Chassis Top (1) | PLA | flat | none | +| Motor Support (1–2) | PLA | as exported | likely none | +| Pi Bottom + Pi Top (1 each) | PLA | flat | none | +| Waveshare Mount (1) | PLA | flat | none | +| C1 Lidar Mount (1) | PLA | flat | check overhangs | +| Camera Mount (1) | PLA | flat | check overhangs | +| Battery Mount (1) | PLA | flat | none | +| Wheel Inner (2) | PLA | hub down | none | +| Wheel Tyre (2) | **TPU** (grip) | flat | none | +| Caster (1) | PLA | — | — | +| SO Base ORP (1, optional) | PLA | flat | none | Notes: + - **Wheels are two-part**: a rigid `Wheel Inner` hub plus a `Wheel Tyre` printed in **TPU** for traction. - **Caster is unresolved** — printed and several off-the-shelf options have all diff --git a/design/BOM.md b/design/BOM.md index 2e1914b..41ee256 100644 --- a/design/BOM.md +++ b/design/BOM.md @@ -15,7 +15,7 @@ build uses a single USB camera. ### Electronics | Part | Qty | Unit price | Link | -| -------------------------------------------------------------- | --- | ---------- | ----------------------------------------------------------------------------------------------------- | +| -------------------------------------------------------------- | --- | ---------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | Raspberry Pi 5 (4GB) | 1 | ~£99 | [CPC Farnell](https://cpc.farnell.com/raspberry-pi/rpi5-4gb-single/raspberry-pi-5-4gb/dp/SC20210) | | Waveshare Serial Bus Servo Driver Board | 1 | ~£15 | [Amazon UK](https://www.amazon.co.uk/dp/B0CJ6TP3TP) | | Feetech STS3215 7.4V servo — 1/191 gear (C044) | 2 | ~£12 | [Alibaba](https://www.alibaba.com/product-detail/Low-Cost-Feetech-STS3215-Servo-7_1601611431055.html) | diff --git a/design/WIRING.md b/design/WIRING.md index 3138062..8bd4950 100644 --- a/design/WIRING.md +++ b/design/WIRING.md @@ -36,79 +36,45 @@ the Pi over their data connection (USB / GPIO), not from the bank directly. ### Power -| From (port) | Cable | To | Carries | Notes | -| --- | --- | --- | --- | --- | -| Bank **Out1** (100 W USB-C) | USB-C → DC 5.5×2.1 mm barrel, **5 V** | Servo board DC input | 5 V power | Must be the higher-current 5 V port — servos brown out on Out2 (see Power notes) | -| Bank **Out2** (45 W USB-C) | USB-C ↔ USB-C, 0.3 m | Pi 5 USB-C power in | 5 V power | Pi negotiates 5 V/5 A | -| Servo board | 3-pin servo lead | Left STS3215 (**ID 7**) | 5 V + 1 Mbaud serial | Left wheel is mounted inverted (sign handled in firmware) | -| Servo board | 3-pin servo lead | Right STS3215 (**ID 9**) | 5 V + 1 Mbaud serial | Servos share the bus; can be daisy-chained | +| From (port) | Cable | To | Carries | +| --------------------------- | ------------------------------------- | ------------------------ | ------- | +| Bank **Out1** (100 W USB-C) | USB-C → DC 5.5×2.1 mm barrel, **5 V** | Servo board DC input | 5 V | +| Bank **Out2** (45 W USB-C) | USB-C ↔ USB-C, 0.3 m | Pi 5 USB-C power in | 5 V | +| Servo board | 3-pin servo lead | Left STS3215 (**ID 7**) | 5 V | +| Servo board | 3-pin servo lead | Right STS3215 (**ID 9**) | 5 V | ### Data -| From | Cable | To | Device node | Notes | -| --- | --- | --- | --- | --- | -| Servo board (USB-C data) | USB-A ↔ USB-C, 0.3 m | Pi USB-A | `/dev/mote_servos` | CH343 USB-serial, 1 Mbaud | -| RPLIDAR C1 | USB (cable supplied with unit) | Pi USB-A | `/dev/mote_lidar` | 460800 baud; powered over USB | -| USB webcam | USB-A (captive) | Pi USB-A | `/dev/mote_camera` | UVC; powered over USB | -| BNO085 (testing) | 4× jumper to GPIO header | Pi GPIO | `/dev/i2c-1` | See IMU section | +| From | Cable | To | Device node | Notes | +| ------------------------ | ------------------------------ | -------- | ------------------ | ----------------------------- | +| Servo board (USB-C data) | USB-A ↔ USB-C, 0.3 m | Pi USB-A | `/dev/mote_servos` | CH343 USB-serial, 1 Mbaud | +| RPLIDAR C1 | USB (cable supplied with unit) | Pi USB-A | `/dev/mote_lidar` | 460800 baud; powered over USB | +| USB webcam | USB-A (captive) | Pi USB-A | `/dev/mote_camera` | UVC; powered over USB | The Pi 5 has 4 USB ports (2× USB-3, 2× USB-2). Servo board, lidar and camera -take three of them; keep the lidar on its own controller if you see scan -dropouts under load. +take three of them. ## Power notes (the fiddly bit) Three things make the 5 V budget non-obvious: -1. **Servos run at 5 V and that's fine.** The STS3215's datasheet operating - range starts at **7.4 V**, but Mote feeds the servo board **5 V** and the - drive works fine in practice — this is confirmed on real hardware, with - `velocity_scale` in [`robot.yaml`](../mote_description/config/robot.yaml) - calibrated at 5 V via `velocity_cal`. Just don't expect the datasheet torque - numbers, since those are quoted at the higher voltage. - -2. **A "100 W" USB-C port is not 100 W at 5 V.** USB-C PD advertises its top - wattage at high voltage (≈20 V); at 5 V each port is limited by its 5 V - current profile — commonly 5 V/3 A (15 W), sometimes 5 V/5 A (25 W). That is - why the servo board must sit on **Out1**: under drive (and especially toward - stall) the two servos draw more 5 V current than the 45 W **Out2** port will - source, and they brown out. This matches the empirical note in - [README.md](README.md#power-bank-form-factor). - -3. **Confirm the barrel cable's profile.** The USB-C→DC cable must request 5 V at - enough current from Out1. **Verify** it negotiates 5 V (not 9/12 V, which - would over-volt the board) and sustains the servo load on your bank. +1. **Servos run at 5 V** The STS3215's + [datasheet](https://www.feetechrc.com/Data/feetechrc/upload/file/20260622/6391772523943436695270694.pdf) + operating range is **4 V - 7.4V**, with the expected value of 6V or 7.4V. + Mote feeds the servo board **5 V** and this seems to work well. Just don't + expect the datasheet torque numbers, since those are quoted at the higher + voltage. + +2. **A "100 W" USB-C port is not 100 W at 5 V.** USB-C PD allows devices to + negotiate different voltage/current depending on need. Focusing on 5V, the + spec allows requesting up to 3A, however it is common for supplies to allow + up to 5A. The Pi explicitly requests 5V/5A = 25W. The servos only need 500mA. ### Rough 5 V budget -| Load | Rail | Typical | Peak | Fed from | -| --- | --- | --- | --- | --- | -| Raspberry Pi 5 | 5 V | 5–10 W | up to ~25 W (5 V/5 A) | Out2 | -| 2× STS3215 (driving) | 5 V via board | ~2–6 W | high near stall | Out1 | -| RPLIDAR C1 | 5 V via Pi USB | 1.15 W (230 mA) | — | Pi | -| USB webcam | 5 V via Pi USB | ~0.5–1 W | — | Pi | -| BNO085 IMU | 3.3 V via GPIO | <0.1 W | — | Pi | - -> ⚠️ Servo stall current at 5 V isn't published (datasheets quote 2.7 A at the -> rated voltage). Treat the servo column as the variable one and size headroom -> accordingly — measuring it on the bench is the only way to pin it down. - -## IMU (BNO085) — in testing - -I²C is the simplest of the BNO085's interfaces. Header pins must be soldered to -the breakout first (see [BOM](BOM.md)). - -| BNO085 pin | Pi 40-pin header | GPIO | -| --- | --- | --- | -| VIN | pin 1 (3V3) | — | -| GND | pin 6 (GND) | — | -| SDA | pin 3 | GPIO 2 | -| SCL | pin 5 | GPIO 3 | - -Enable I²C (`raspi-config` → Interfaces, or `dtparam=i2c_arm=on`); the BNO085 -appears at address `0x4A` (or `0x4B`) — check with `i2cdetect -y 1`. Intended use -is wheel-slip detection / odometry fusion via `robot_localization`. - -> ⚠️ **Verify** your breakout's logic/voltage: most BNO085 boards have an -> onboard regulator and accept 3–5 V on VIN, but confirm yours before wiring to -> 3V3 vs 5V. +| Load | Rail | Typical | Peak | Fed from | +| -------------------- | -------------- | --------------------- | -------------- | -------- | +| Raspberry Pi 5 | 5 V | 5–10 W | 25 W (5 V/5 A) | Out2 | +| 2× STS3215 (driving) | 5 V via board | 1-2 W (130mA no load) | 10W (2A stall) | Out1 | +| RPLIDAR C1 | 5 V via Pi USB | 1.15 W (230 mA) | — | Pi | +| USB webcam | 5 V via Pi USB | ~0.5–1 W | — | Pi | From 1bceb2165fe93dee2953f2f7c7239667631ceb3f Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 16:53:17 +0100 Subject: [PATCH 12/17] Update readme --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 77d76a0..4d9c702 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,6 @@ 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 ``` -The `sim` and `sim-test` tasks select the sim environment automatically (they're -defined only there); the bare `pixi run -- …` form defaults to the robot -environment, so those need `-e sim`. - `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 @@ -209,14 +205,6 @@ pixi run sync # one-shot push pixi run sync-watch # keep pushing on every save (needs the dev env) ``` -**Fast inner loop:** the build uses `colcon build --symlink-install`, so once -you've built once on the Pi, edits to existing launch files, `robot.yaml`, -controller params, or Python launch logic take effect the next time you launch — -**no rebuild needed**. Only changes to the `mote_hardware` C++ (or brand-new -files that need installing) require another `pixi run build`. So the usual loop -is: `sync-watch` running in one terminal, edit on the laptop, re-run `pixi run -launch` on the Pi. - 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 From 9d0234e52adae52dd9fcf7a9801648f0c45a63c8 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:09:51 +0100 Subject: [PATCH 13/17] Cleanup assembly instructions --- design/ASSEMBLY.md | 73 +++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/design/ASSEMBLY.md b/design/ASSEMBLY.md index a777da0..f66a99f 100644 --- a/design/ASSEMBLY.md +++ b/design/ASSEMBLY.md @@ -17,20 +17,20 @@ All parts fit a 256 mm bed (the chassis is 235 mm — see Suggested defaults: **PLA**, 0.2 mm layer height, 3 walls, 15–20% infill, brim on the large flat plates to prevent corner lift. -| Part (qty) | Material | Orientation | Supports | -| --------------------------- | -------------- | ----------- | --------------- | -| Chassis Base (1) | PLA | flat | none | -| Chassis Top (1) | PLA | flat | none | -| Motor Support (1–2) | PLA | as exported | likely none | -| Pi Bottom + Pi Top (1 each) | PLA | flat | none | -| Waveshare Mount (1) | PLA | flat | none | -| C1 Lidar Mount (1) | PLA | flat | check overhangs | -| Camera Mount (1) | PLA | flat | check overhangs | -| Battery Mount (1) | PLA | flat | none | -| Wheel Inner (2) | PLA | hub down | none | -| Wheel Tyre (2) | **TPU** (grip) | flat | none | -| Caster (1) | PLA | — | — | -| SO Base ORP (1, optional) | PLA | flat | none | +| Part (qty) | Material | +| --------------------------- | -------------- | +| Chassis Base (1) | PLA | +| Chassis Top (1) | PLA | +| Motor Support (2) | PLA | +| Pi Bottom + Pi Top (1 each) | PLA | +| Waveshare Mount (1) | PLA | +| C1 Lidar Mount (1) | PLA | +| Camera Mount (1) | PLA | +| Battery Mount (1) | PLA | +| Wheel Inner (2) | PLA | +| Wheel Tyre (2) | **TPU** (grip) | +| Caster (1) | PLA | +| SO Base ORP (1, optional) | PLA | Notes: @@ -39,37 +39,36 @@ Notes: - **Caster is unresolved** — printed and several off-the-shelf options have all been unsatisfactory so far; treat this part as provisional. A bought ball caster of the right height may be preferable (see Assembly step 6). -- Drive wheels and hubs are intentionally printed (removed from the - [BOM](BOM.md) for that reason). ## Assembly -**Hardware:** the M3 button-head hex set from the [BOM](BOM.md) (screws, nuts, -washers) covers all fasteners — its M3×6–35 range means you can match each joint -exactly. Most joints take **M3×12**: the screw passes through a ~6 mm plate into -a part with a ~6 mm captive-nut pocket. Use **M3×10** for thinner stacks. -Standoffs are buffered to **50 mm** (set by servo height 45.2 mm / lidar -41.3 mm). +**Hardware:** the M3 button-head hex set from the [BOM](BOM.md) (screws, nuts) +covers all fasteners. Most joints take **M3×12**: the screw passes through a ~6 +mm plate into a part with a ~6 mm captive-nut pocket. Use **M3×10** for thinner +stacks. + +> **Note:** I am not convinced yet that screws and nuts are the optimal choice +> here. Nuts have a tendency to loosen up due to vibrations while driving around +> so we may want something more reliable. Suggested order: -1. **Drive train.** Mount each STS3215 to the `Motor Support`, then press a - `Wheel Inner` onto the servo horn and fit the `Wheel Tyre` over it. Wheels are - centred and inset so the footprint stays circular. Left servo is **ID 7**, - right is **ID 9** (assign with `pixi run setup-ids` — see the +1. **Servos.** Mount each STS3215 to the `Motor Support`, then press a + `Wheel Inner` onto the servo horn and fit the `Wheel Tyre` over it. Wheels + are centred and inset so the footprint stays circular. It's easiest to set + the servo ID's before going further. Left servo is **ID 7**, right is **ID + 9** (assign with `pixi run setup-ids` — see the [README](../README.md#4-configure-the-servos)). -2. **Lower plate.** Fasten the `Motor Support`/servos and the `Battery Mount` to - the `Chassis Base`. -3. **Power bank sandwich.** Seat the power bank in the `Battery Mount` between the - plates to keep the centre of mass low, then stand the **50 mm** standoffs up - from the base. -4. **Electronics deck.** Mount the Pi in the `Pi Bottom`/`Pi Top` holder and the - servo board on the `Waveshare Mount`, then attach both to the `Chassis Top`. -5. **Close it up.** Fix the `Chassis Top` onto the standoffs. -6. **Caster.** Fit the front `Caster` to the underside of the base for the third +2. **Lower plate.** Fasten the `Motor Support`/servos, the `Battery Mount`, the + `Waveshare Mount`, and the `C1 Lidar Mount`, to the `Chassis Base`. +3. **Caster.** Fit the front `Caster` to the underside of the base for the third contact point (provisional — see Printing notes). -7. **Sensors.** Mount the `C1 Lidar Mount` and `Camera Mount` to the sensor slots - — these follow the ORP **3.5 mm / 20 mm grid**, so they relocate on the grid. +4. **Sensors.** Mount the camera in the `Camera Mount`. +5. **Top plate.** Attach the `Pi Bottom` holder and the `Camera/Camera Mount` to + the `Chassis Top`. +6. **Close it up.** Fix the `Chassis Top` onto the standoffs. +7. **Power bank.** Seat the power bank in the `Battery Mount` between the + plates. 8. **Wire it.** Follow [WIRING.md](WIRING.md): bank Out1 → servo board (barrel), bank Out2 → Pi, servo board USB → Pi, lidar/camera USB → Pi. Route cables through the standoff gap and keep the lidar's 360° view clear. From 0b2d62fc28d99fe6e333674a69c97f740029531d Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:10:38 +0100 Subject: [PATCH 14/17] Fix BOM table --- design/BOM.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/BOM.md b/design/BOM.md index 41ee256..2e1914b 100644 --- a/design/BOM.md +++ b/design/BOM.md @@ -15,7 +15,7 @@ build uses a single USB camera. ### Electronics | Part | Qty | Unit price | Link | -| -------------------------------------------------------------- | --- | ---------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| -------------------------------------------------------------- | --- | ---------- | ----------------------------------------------------------------------------------------------------- | | Raspberry Pi 5 (4GB) | 1 | ~£99 | [CPC Farnell](https://cpc.farnell.com/raspberry-pi/rpi5-4gb-single/raspberry-pi-5-4gb/dp/SC20210) | | Waveshare Serial Bus Servo Driver Board | 1 | ~£15 | [Amazon UK](https://www.amazon.co.uk/dp/B0CJ6TP3TP) | | Feetech STS3215 7.4V servo — 1/191 gear (C044) | 2 | ~£12 | [Alibaba](https://www.alibaba.com/product-detail/Low-Cost-Feetech-STS3215-Servo-7_1601611431055.html) | From 11ca0a038663d26cb9146cfaf7ec1f7b9414f2b7 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:15:31 +0100 Subject: [PATCH 15/17] Split PR --- .pre-commit-config.yaml | 38 ------ CLAUDE.md | 12 +- README.md | 9 -- mote_bringup/test/sim_smoke/verify_sim.py | 149 ---------------------- pixi.toml | 13 -- 5 files changed, 3 insertions(+), 218 deletions(-) delete mode 100644 .pre-commit-config.yaml delete mode 100755 mote_bringup/test/sim_smoke/verify_sim.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index a77b85e..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# 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 502f029..02a93ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,17 +24,11 @@ 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). 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: +# 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.: # 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 4d9c702..6d5bbdf 100644 --- a/README.md +++ b/README.md @@ -229,15 +229,6 @@ 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/verify_sim.py b/mote_bringup/test/sim_smoke/verify_sim.py deleted file mode 100755 index c93b17d..0000000 --- a/mote_bringup/test/sim_smoke/verify_sim.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/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 1ec5da1..19c994f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -84,23 +84,12 @@ 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" } @@ -109,5 +98,3 @@ 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 } From e06bc90a31ab3fd67d796185495264cf5223b6af Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:17:17 +0100 Subject: [PATCH 16/17] Remove extra paragraph in readme --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 6d5bbdf..9dd8c92 100644 --- a/README.md +++ b/README.md @@ -171,20 +171,12 @@ 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). From 2c37bd351eac5fc7c60e9520879723c1f21d2e32 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 22 Jun 2026 17:18:19 +0100 Subject: [PATCH 17/17] Final PR cleanup --- README.md | 6 -- mote_bringup/test/sim_smoke/run_sim_smoke.sh | 66 -------------------- 2 files changed, 72 deletions(-) delete mode 100755 mote_bringup/test/sim_smoke/run_sim_smoke.sh diff --git a/README.md b/README.md index 9dd8c92..d71a46e 100644 --- a/README.md +++ b/README.md @@ -197,12 +197,6 @@ 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) diff --git a/mote_bringup/test/sim_smoke/run_sim_smoke.sh b/mote_bringup/test/sim_smoke/run_sim_smoke.sh deleted file mode 100755 index c13d2a0..0000000 --- a/mote_bringup/test/sim_smoke/run_sim_smoke.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/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"