I did not set out to build a better robot vacuum. There are already good ones on the market, and competing with iRobot or Roborock on a hobbyist budget is a losing game. What I actually wanted was a realistic, physically grounded platform to force myself through the full mobile robotics stack: URDF modeling, sensor fusion, SLAM, Nav2, ros2_control, and eventually sim-to-real transfer of learned navigation policies. A vacuum form factor turned out to be a convenient excuse to build that platform.
This post documents where the project currently stands: the mechanical starting point, the ROS 2 architecture, the simulation setup in Gazebo Harmonic, the decisions I made and why, and the failures that shaped those decisions. This is not a tutorial. It is a log of an ongoing system, written the way I'd want to read about someone else's project before starting my own.
Why a Vacuum Form Factor
Designing a mobile robot chassis from a blank sheet is a rabbit hole. Wheel placement, center of mass, caster geometry, motor mounting, all of it consumes weeks before you've written a single line of navigation code. I wanted to spend my time on the software stack, not on reinventing a differential drive chassis that thousands of engineers have already solved well.
So I pulled a CAD model of a robot vacuum (visually similar to the Xiaomi Mi Robot Vacuum, though this is not a Xiaomi product or a clone of their firmware) from GrabCAD and imported it into Onshape. From there I started modifying the internal volume to fit a Raspberry Pi 5, a battery pack sized for my actual power budget, and mounting points for an RPLIDAR and an IMU that the stock design never accounted for.
The reasoning here is purely pragmatic:
- The circular chassis with a differential drive base and a single rear caster is a well understood, well documented robot geometry. Every Nav2 tutorial, every
robot_localizationexample, everyros2_controldiff drive plugin assumes something close to this. - A commercial design has already solved weight distribution and turning radius problems for indoor navigation. I don't need to relearn those from scratch.
- It gives me a hard physical constraint. I can't just theorize about SLAM performance, the chassis has a real height, a real sensor mounting position, and a real minimum turning radius that the planner has to respect.
The trade-off is that I inherited some annoying constraints too. Internal volume is tight, which is already forcing compromises on where the RealSense camera goes relative to the LiDAR, and cable routing inside a vacuum shell is a genuinely fiddly problem that CAD renders don't warn you about.
Current Hardware
The hardware set is intentionally modest. Nothing here is exotic, and that is deliberate: I want the eventual sim-to-real gap to come from software and modeling decisions, not from working around unusual or unreliable components.
| Subsystem | Component |
|---|---|
| Chassis | Circular vacuum-form platform (Onshape, modified from GrabCAD reference) |
| Drive | Differential drive, two powered wheels, rear caster |
| Compute | Raspberry Pi 5 |
| Ranging | RPLIDAR (2D planar LiDAR) |
| Inertial | IMU |
| Odometry | Wheel encoders on both drive motors |
| Depth (optional) | Intel RealSense |
| Actuation | Vacuum motor, brush motor, drive motors |
| Power | Custom BMS and charging circuit |
The RealSense is marked optional because right now the navigation stack is LiDAR-centric. The camera exists for future work: visual odometry as a cross-check against wheel encoders, and eventually as an input to a learned local planner. It is not load-bearing for the current SLAM or Nav2 pipeline.
Software Stack
- Ubuntu 24.04
- ROS 2 Jazzy
- Gazebo Harmonic
- Navigation2
- SLAM Toolbox
- robot_localization
- ros2_control
- URDF / Xacro
- RViz2
- Lifecycle nodes
- TF2
- Behavior Trees (via Nav2's BT navigator)
- OpenCV
- Python and C++, split by where each language actually earns its place
The language split is worth a sentence. Anything performance sensitive, control loops, message filtering, custom ros2_control hardware interfaces, goes in C++. Anything orchestration or logic heavy, behavior tree conditions, state machines, tooling scripts, goes in Python. I've seen projects standardize on one language across the whole stack and pay for it later, either in runtime overhead in the Python parts or in development speed in the C++ parts.
Architecture: One Set of Nodes, Two Environments
The single design decision that shapes everything else in this project is: the same ROS 2 nodes and topics run in simulation and on the physical robot. Not "similar" nodes. The same navigation, localization, and control stack, unmodified, pointed at a different set of drivers underneath.
Gazebo Harmonic
│
Same ROS 2 Nodes & Topics
(Nav2, SLAM Toolbox, robot_localization,
TF2, Behavior Trees)
│
┌──────────────────────┴──────────────────────┐
│ │
SIMULATION PHYSICAL ROBOT
───────────── ─────────────
LiDAR plugin ───────────────► RPLIDAR driver
Camera plugin ───────────────► RealSense driver
Diff drive plugin ───────────────► Motor driver (ros2_control)
IMU plugin ───────────────► Physical IMU driver
Wheel encoder plugin ───────────────► Hardware encoder interface
Everything above the dashed line, the planner, the costmaps, the EKF, the behavior tree, never knows whether it's talking to a Gazebo plugin or a physical driver. It just sees /scan, /imu, /odom, /cmd_vel, and /tf. The abstraction boundary is the hardware interface layer, implemented through ros2_control's SystemInterface on the real robot and through Gazebo's ros2_control plugin in simulation.
This is not a novel idea, it is how Clearpath and most serious ROS 2 platforms structure their stacks, but actually building it disciplined enough to hold up is harder than it sounds. The temptation to sneak a simulation-only shortcut into a launch file, or to hardcode a topic remap that only makes sense on hardware, is constant. Every time I catch myself doing that, it's a sign the abstraction has a hole in it.
URDF and Xacro Structure
The robot description is split into modular Xacro files rather than one monolithic URDF, which matters once you're maintaining both a simulated and a physical variant:
vacuum_bot_description/
├── urdf/
│ ├── vacuum_bot.urdf.xacro # top level, includes everything below
│ ├── base.xacro # chassis, casters, inertial properties
│ ├── wheels.xacro # drive wheel joints and links
│ ├── lidar.xacro # RPLIDAR mount and frame
│ ├── imu.xacro # IMU mount and frame
│ └── ros2_control.xacro # hardware interface tags, sim vs real toggle
├── meshes/
└── config/
├── controller_manager.yaml
└── nav2_params.yaml
The ros2_control.xacro file is the one place where simulation and hardware genuinely diverge, gated by a Xacro argument:
<xacro:if value="$(arg use_sim)">
<ros2_control name="GazeboSystem" type="system">
<hardware>
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</hardware>
<!-- joint definitions shared with the real robot below -->
</ros2_control>
</xacro:if>
<xacro:unless value="$(arg use_sim)">
<ros2_control name="RealRobot" type="system">
<hardware>
<plugin>vacuum_bot_hardware/VacuumBotSystemHardware</plugin>
<param name="serial_port">/dev/ttyUSB0</param>
</hardware>
<!-- same joint definitions -->
</ros2_control>
</xacro:unless>
Everything else in the URDF, link geometry, joint limits, sensor frames, inertial tensors, stays identical between the two. That's the whole point: the divergence is quarantined to a single file and a single tag, not scattered across launch files and node parameters.
SLAM and Localization
SLAM Toolbox runs in online async mode for mapping, and the localization stack is a two-layer setup that I think is underused in hobbyist projects: wheel encoder odometry fused with the IMU through robot_localization's EKF node, producing odom -> base_link, with SLAM Toolbox (or AMCL against a saved map, depending on mode) supplying the map -> odom correction.
map
└── odom (published by SLAM Toolbox / AMCL)
└── base_link (published by robot_localization EKF, fusing wheel odom + IMU)
├── lidar_link
├── imu_link
└── camera_link (when RealSense is mounted)
This two-frame separation matters more than it looks like on paper. Early on I had SLAM Toolbox publishing directly into base_link without an EKF in between, and the localization estimate was visibly jittery on carpet, since raw wheel odometry alone is noisy and the LiDAR match correction was fighting that noise at high frequency. Fusing wheel odometry with the IMU first gives SLAM Toolbox a much smoother prior to correct against, which is a cheap fix that made a disproportionate difference in map quality.
Simulation Environment
Gazebo Harmonic hosts the differential drive plugin, LiDAR sensor plugin, IMU plugin, and (optionally) the RGBD camera plugin, all wired through gz_ros2_control so that the same diff_drive_controller and joint_state_broadcaster used on hardware also drive the simulated joints. Test worlds are simple apartment-style layouts built from primitive geometry: rooms, doorways, furniture obstacles, and a charging dock marker. Nothing photorealistic, since the goal right now is validating the navigation and control stack, not perception under realistic lighting.
Simulation-first was not really a choice so much as the only sane option given the hardware situation. Bench testing a differential drive controller tuning by running it on carpet, watching the robot drift into a wall, powering down, adjusting a PID gain, and repeating, burns hours. In Gazebo, the same iteration loop is seconds, and I can reset the world state exactly, which matters when you're trying to isolate whether a bad turn came from controller tuning or from a bug in your costmap inflation layer.
Navigation Stack
Nav2 runs with the standard lifecycle-managed node set: the controller server (DWB), the planner server (NavFn), behavior server, BT navigator, and costmap layers (static, obstacle, inflation) on both the local and global costmaps. Behavior tree XML is close to the Nav2 default navigate_w_replanning_and_recovery tree for now, with the recovery behaviors tuned down since a circular, low-clearance vacuum chassis has different recovery needs than a taller differential drive robot: spin recovery is cheap, but backing up in a cluttered room is riskier given the low sensor mounting height and limited field of view behind the robot.
# excerpt, nav2_params.yaml
controller_server:
ros__parameters:
controller_frequency: 20.0
FollowPath:
plugin: "dwb_core::DWBLocalPlanner"
max_vel_x: 0.26
max_vel_theta: 1.0
acc_lim_x: 2.5
acc_lim_theta: 3.2
xy_goal_tolerance: 0.15
trajectory_generator_name: "dwb_plugins::StandardTrajectoryGenerator"
max_vel_x is intentionally conservative at this stage. It is easy to tune a planner for aggressive speed in simulation where friction and wheel slip are idealized, and then watch that same configuration fail on carpet where actual traction is lower. I would rather start slow, get a sim-to-real gap measurement, and open the throttle once I know what the gap actually looks like.
Why ROS 2 Package Architecture Actually Matters
Early on I underweighted package organization, treating it as bookkeeping rather than engineering. That was a mistake worth describing, because the cost of a flat, poorly separated package structure doesn't show up immediately, it shows up three months in when you're trying to swap a component and discover everything is entangled.
The workspace is currently split into distinct packages along clear responsibility boundaries:
vacuum_bot_ws/src/
├── vacuum_bot_description/ # URDF, Xacro, meshes, robot_state_publisher config
├── vacuum_bot_bringup/ # launch files, top-level orchestration
├── vacuum_bot_hardware/ # ros2_control hardware interface (real robot only)
├── vacuum_bot_gazebo/ # world files, sim-specific launch, sensor noise configs
├── vacuum_bot_navigation/ # nav2_params.yaml, BT XML, costmap configs
├── vacuum_bot_localization/ # EKF config, SLAM Toolbox params
└── vacuum_bot_msgs/ # custom messages (battery state, dock status)
The rule I've tried to hold myself to is that vacuum_bot_description and vacuum_bot_navigation should never need to know whether they're running against Gazebo or hardware. Only vacuum_bot_bringup (which selects launch arguments) and vacuum_bot_hardware versus vacuum_bot_gazebo (which are mutually exclusive by design) are allowed to know that. When I catch a navigation-related parameter file with a sim-only assumption baked in, that's a sign the boundary leaked, and I go fix it rather than letting it slide.
This also made testing dramatically easier. Because vacuum_bot_navigation has no simulation dependency, I can write and run behavior tree condition unit tests, costmap layer configuration validation, and launch file argument checks in CI without spinning up Gazebo at all. Gazebo-dependent integration tests are a separate, slower tier that runs less frequently.
Why Sensor Integration Was Harder Than Expected
I underestimated this part going in, and it's worth being specific about where the difficulty actually came from, because "sensor integration is hard" is a vague complaint.
Frame conventions. RPLIDAR's ROS 2 driver publishes in a frame convention that does not automatically agree with where I mounted the physical unit inside the chassis. Getting lidar_link's static transform right, including a 180 degree yaw correction, took longer than it should have because the symptom (a map that looked mirrored and rotated) didn't obviously point at the transform as the cause. It looked, at first glance, like a SLAM Toolbox configuration problem.
Timing and message synchronization. The IMU publishes at a different rate than the wheel encoders, and robot_localization's EKF is sensitive to how you configure the process noise covariance relative to those rates. My first EKF config trusted the IMU too much relative to wheel odometry, which showed up as orientation drift during pure rotation in place, a scenario where wheel odometry is actually quite reliable and the IMU's gyro bias was the weaker signal.
Simulated sensor noise not matching reality. Gazebo's default LiDAR and IMU noise models are close to ideal by default. If you tune a costmap inflation radius or an EKF covariance against noiseless simulated sensors, you are tuning against a fantasy. I've since added Gaussian noise to the simulated LiDAR and IMU that roughly matches the RPLIDAR and IMU datasheet specs, which is a small thing that meaningfully changes what "good tuning" looks like in sim.
None of these are exotic problems. They're exactly the kind of unglamorous debugging that a tutorial skips over and that eats real project time.
Debugging Workflow: TF, rqt, and Rosbag
A few tools ended up carrying most of the debugging load, and it's worth naming them since they're unglamorous compared to whatever the current SLAM or planning algorithm is, but they're where most of the actual time goes.
ros2 run tf2_tools view_frames was the single most useful command during the sensor frame debugging described above. Generating the TF tree as a PDF and actually looking at it, rather than guessing at transforms from launch file arguments, caught the mirrored LiDAR frame issue faster than staring at RViz would have.
rqt_graph earns its place whenever a topic isn't flowing where I expect. Nav2's node graph gets large enough that eyeballing launch files isn't reliable for confirming remaps actually took effect; rqt_graph shows the ground truth.
Rosbag recording of every hardware test run, once hardware testing starts, is a policy I'm committing to before the first bring-up rather than after. It's tempting to skip recording during "just a quick test," and that's exactly when the interesting failure happens and there's no data to look back at. Every sim run already gets this treatment; I want hardware runs held to the same standard from day one.
RViz2 with a saved perspective per debugging context (one for SLAM map-building, one for Nav2 costmap inspection, one for TF/sensor frame checks) sounds trivial but cut a meaningful amount of time spent reconfiguring displays mid-debug session.
Sim-to-Real: The Actual Goal
Everything above exists in service of a specific question: once the stack runs cleanly in Gazebo, what breaks, and by how much, when the same nodes point at real hardware?
The intended workflow, in order:
- Build the robot model (CAD, then URDF/Xacro)
- Validate the model in Gazebo (physics, joint limits, sensor placement)
- Tune controllers in simulation (
diff_drive_controller, DWB parameters) - Integrate sensors (RPLIDAR, IMU, encoders, on the real hardware interface)
- Validate navigation in simulation (SLAM Toolbox, Nav2 end to end)
- Deploy the unchanged ROS 2 stack to the physical robot
- Measure the sim-to-real gap directly, rather than guessing at it
- Feed that measurement back into simulation fidelity and controller tuning
Steps 1 through 5 are largely done. Step 6 (hardware deployment) and everything after it is the current frontier of the project. The specific gaps I expect to measure, and want to measure rather than assume, are wheel slip on hard floors versus carpet, localization drift over multi-minute runs, control loop timing differences between the RCLCPP executor on a Raspberry Pi 5 versus my development machine, and how much costmap inflation tuning needs to change once real LiDAR noise replaces the noise model I approximated in Gazebo.
Domain randomization is explicitly future work, not something implemented yet. The plan is to randomize friction coefficients, sensor noise parameters, and lighting (once the RealSense is doing anything perception-relevant) across training episodes once the learned navigation component exists, so that a policy trained in simulation isn't brittle to the specific physics parameters of one Gazebo world.
The AI Component: Augmenting, Not Replacing
This is explicitly not an AI-first project, and I want to be direct about scope so it doesn't sound like vague roadmap padding. The traditional stack, Nav2's DWB local planner, NavFn global planner, SLAM Toolbox, robot_localization, remains the backbone. It works, it's debuggable, and it doesn't require training data I don't have yet.
The research question I actually want to explore is narrower: can a learned local planner, trained via imitation learning against DWB's own trajectories or via reinforcement learning in Gazebo, match or improve on classical local planning in cluttered, dynamic indoor scenes, specifically doorway navigation and tight furniture gaps where DWB's trajectory scoring sometimes gets conservative. That's a local planner replacement experiment, not a full navigation stack replacement. Global planning, costmaps, and localization stay classical.
Nothing here is trained yet. This section is future work, clearly labeled as such, not a claim about current capability.
Trade-offs Worth Naming Explicitly
Reusing a commercial chassis versus designing from scratch. I saved months of mechanical design time, at the cost of inheriting a tight internal volume that constrains sensor placement and cable routing. For a software-focused project, that trade was correct. For a project where the mechanical design itself was the research question, it would not have been.
LiDAR-centric navigation versus camera-first. 2D LiDAR plus Nav2's costmap stack is a mature, well-understood combination with predictable failure modes. A camera-first approach (visual SLAM, depth-based obstacle avoidance) would open up richer perception but at meaningfully higher software risk for a first working system. I chose to get LiDAR-based navigation solid first and treat the RealSense as an additive sensor for future work, not a dependency for the current pipeline.
Raspberry Pi 5 versus a more capable embedded compute platform. The Pi 5 is enough to run the current ROS 2 graph, but I am watching CPU headroom closely, particularly once any learned inference component gets added. I'd rather hit that ceiling and make a deliberate compute upgrade decision later than over-provision compute for a stack that doesn't need it yet.
Simulation fidelity versus development speed. Idealized simulated sensors made early development faster but produced tuning that didn't transfer well. Adding realistic noise models slowed simulation development slightly but should narrow the sim-to-real gap. I'd make the same trade again, just earlier next time.
Where AWS Fits (and Where It Doesn't)
I want to be clear that the robot's autonomy does not depend on the cloud in any way. Navigation, SLAM, and control all run on-device. Where cloud tooling actually earns a place in this project is around the development and evaluation loop rather than the robot's runtime behavior:
- Storing Gazebo simulation logs and rosbag recordings from both sim runs and hardware test runs, so sim-to-real comparisons are reproducible instead of anecdotal
- A container registry for the ROS 2 workspace image, so the exact same environment that ran a given test is easy to rebuild later
- A lightweight CI pipeline that builds the ROS 2 packages and runs unit tests on push, catching build breaks before they show up as "why doesn't this launch anymore" during bench time
- Eventually, fleet-style telemetry if this project ever grows past a single unit, though that's speculative and not close to current scope
None of this changes how the robot operates. It's infrastructure around the project, not infrastructure the robot leans on to function.
Current Status and Immediate Next Steps
As of this writing: the URDF/Xacro model is built and validated in Gazebo Harmonic, ros2_control is wired for both the simulated and real hardware interface (with the real hardware interface implemented but not yet bench-tested end to end), SLAM Toolbox produces usable maps in simulated apartment-style worlds, and Nav2 completes point-to-point navigation goals in simulation with the DWB local planner and NavFn global planner.
The immediate next milestone is step 6 in the sim-to-real workflow: flashing the same ROS 2 stack onto the Raspberry Pi 5, bringing up the real RPLIDAR and IMU drivers, and running the first physical SLAM mapping pass. That's where the real measurements start, and where I expect this write-up to get a follow-up post with actual numbers instead of projected ones.
Future Work
- Physical hardware bring-up and first real-world SLAM/Nav2 runs
- Quantified sim-to-real gap measurements (localization drift, wheel slip, control timing)
- Domain randomization in simulation once measured gaps identify what to randomize
- Imitation learning baseline for a learned local planner, benchmarked against DWB
- RealSense integration as a secondary localization and obstacle avoidance signal
- Charging dock detection and autonomous docking behavior
- Fleet-style telemetry and logging infrastructure, if the project scope grows
This is a long-horizon project, and I'd rather document it honestly in stages than wait for a finished product that may never arrive in the form I'm currently imagining. If you're working on something similar, particularly the sim-to-real side, I'd genuinely like to compare notes.
Top comments (0)