Phat C. Vo
← Back to Writing

I'm Building an AMR from Zero

Published Reading 12 min

Companion to video 01. 📺 Watch: link coming with the video.

This is the first article of a build log. Over the series a robot goes from an empty workspace to a machine that drives itself around a warehouse: it learns to move, to feel its own motion, to see, to map, to know where it is, to plan a route, to stop before it hits something, and eventually to work alongside other robots.

This article is the map of that journey.

1. What is an AMR?

An AMR — Autonomous Mobile Robot — is a robot that moves through an environment on its own, deciding its own route.

The distinction people usually reach for is against an AGV (Automated Guided Vehicle):

AGVAMR
How it follows a routefixed infrastructure — magnetic tape, buried wire, reflectorsa map plus onboard sensing
Blocked pathstops and waitsplans around it
Changing the routere-lay the tapeedit the map
What it needs from the buildinga lotalmost nothing

That difference is not really about wheels or motors. It is about what the robot knows. An AGV knows one line. An AMR knows a map, its own position on that map, and what is currently in front of it. Everything in this series is about building up that knowledge, one sensor and one algorithm at a time.

2. Meet BEEBOT2

The robot in this series is BEEBOT2, a differential-drive indoor AMR: two driven wheels on a common axis, plus casters for balance. Steering is done by driving the two wheels at different speeds — no steering rack, no servo.

Left: BEEBOT2 seen from the front with the side panels open, showing the status panel and the internal decks. Right: the same robot with its outer shell closed.
Front and closed-shell views. The status panel — pack state of charge, e-stop, two USB ports — is the only part of the robot an operator ever touches.

The workspace also carries a second, much larger platform called AMR, a 300 kg warehouse vehicle whose geometry was measured from a 2018 machine. Both robots share the same code; the same launch files bring up either one. That is deliberate — it is the cheapest possible proof that nothing in the stack is secretly hard-coded to one chassis.

BEEBOT2AMR
Envelope0.50 × 0.53 m1.14 × 0.94 m
Chassis0.43 × 0.43 × 0.40 m1.00 × 0.80 × 0.38 m
Mass40 kg300 kg
Wheels⌀0.13 m, track 0.48 m⌀0.22 m, track 0.663 m
Scanners360° roof + 180° front safety2 × 270° corner, merged to 360°
Swept diameter0.729 m1.477 m
Liftnoyes

Note — BEEBOT2 is wider than it is long.

The drive wheels sit outboard of the 0.43 m shell and reach ±0.265 m in y, while the caster spheres only reach ±0.25 m in x. This is easy to get backwards, and getting it backwards means the navigation footprint is wrong in the one direction that matters when squeezing through a doorway. The footprint follows the envelope, not the chassis.

3. The hardware

A cutaway three-quarter view of BEEBOT2 with the side panels removed, showing the roof plate, the front status panel, two internal equipment decks, one drive wheel and a caster.
Roof plate — the 360° scanner mounts here
Status panel — pack SoC, e-stop, USB
Upper deck — controller and I/O
Lower deck — battery pack and BMS
Drive wheel — one of two, on a common axis
Caster — balance only, never driven
The same render with the side panels off. Everything below is one of these six things.

That render says where things sit. It does not say what is plugged into what — and on a robot, the second question is the one that costs weekends.

BEEBOT2 — hardware block diagram

One pack, one PC, two buses

Power

Lithium pack + DALY BMS

24 V nominal — the only energy source on board

Distribution & compute

Distribution + E-stop

fused 24 V rail, drive enable in series

  • ⬜ no physical E-stop wired yet

PC — Ubuntu + ROS 2 Jazzy

the whole stack, in Docker

Sensors & drive

LiDAR × 2

360° roof @ 10 Hz · 180° front @ 15 Hz

  • ⬜ no driver in the workspace

IMU — 3DM-GX5-AHRS

rotation rate + acceleration

  • ⬜ no driver in the workspace
The MD200T two-channel BLDC controller

MDROBOT MD200T

2 channels, 12–48 V, 10 A each

Actuators

A rubber-tyred BLDC hub motor

2 × BLDC hub motors

ZLLG65ASM250-L — ⌀0.13 m, 6.5 N·m

  • Lithium pack + DALY BMSDistribution + E-stop24 V
  • Lithium pack + DALY BMSPC — Ubuntu + ROS 2 JazzyBMS · USB, 9600 baud
  • Distribution + E-stopPC — Ubuntu + ROS 2 Jazzyregulated DC
  • Distribution + E-stopMDROBOT MD200T24 V drive rail
  • PC — Ubuntu + ROS 2 JazzyMDROBOT MD200TUSB ↔ RS485
  • LiDAR × 2PC — Ubuntu + ROS 2 Jazzy/scan · /scan_front
  • IMU — 3DM-GX5-AHRSPC — Ubuntu + ROS 2 Jazzy/imu/data
  • MDROBOT MD200T2 × BLDC hub motors3-phase, 2 channels
  • 2 × BLDC hub motorsMDROBOT MD200Tencoders / hall
  • DC power
  • RS485 — the drive bus
  • USB / serial — everything else
  • Motor phase (3-ph)
  • Encoder / Hall
Two buses and one PC. The drive is alone on RS485; the pack and every sensor reach the PC over USB — which is why article 10 spends time on udev rules, because a reboot that reorders ttyUSB0/1/2 silently points the motor driver at the battery. Not drawn: the RGBD camera and the bumper are fitted, and nothing in the stack reads either of them yet.

Two buses and one PC is the whole shape of it: the drive alone on RS485, everything else on USB, one 24 V rail leaving the pack. The three ⬜ blocks are hardware that is bolted to the robot and has nothing driving it yet, which is most of why section 6 reads the way it does.

Drive

Two BLDC gear motors, driven by a single MDROBOT MD200T two-channel controller (DC 12–48 V, 10 A per channel). Both wheels hang off one RS485 bus. Article 02 is entirely about getting this link alive.

Power

A lithium pack with a DALY BMS. The BMS speaks a serial protocol that gives pack voltage, current and state of charge — which becomes a sensor_msgs/BatteryState topic, so the rest of the stack can ask “how much runtime is left?” without knowing anything about batteries. Article 10.

Sensing

SensorWhat it gives the robot
LiDARdistance to everything around it, ~15 times a second
IMU — MicroStrain 3DM-GX5-AHRSacceleration and rotation rate — how it is actually moving
Wheel encodershow far each wheel turned
RGBD cameracolour + depth, for higher-level perception later
Bumperphysical contact, the last line of defence

Compute

A single PC running Linux and ROS 2. Everything in the series runs in Docker, so the development machine and the robot run the same image and the “it works on my laptop” class of bug simply cannot happen.

4. The software

ROS 2 Jazzy on the robot, Gazebo Harmonic for simulation. The pieces:

LayerWhat it does
ros2_controlthe seam between “wheel velocity” and “bytes on a wire”
diff_drive_controllerturns a velocity command into left/right wheel speeds, and encoder counts back into odometry
robot_localization (EKF)fuses wheel odometry with the IMU into one better estimate
slam_toolboxbuilds a map from LiDAR while the robot drives
AMCLfinds the robot’s position on a map it already has
Nav2plans a path to a goal and follows it
twist_muxdecides whose velocity command wins when several are talking at once

Split across packages, that is:

PackageContents
beebot2_descriptionURDF/xacro, meshes, geometry regression tests
beebot2_motorMD200T drive over RS485, plus a bring-up probe tool
beebot2_controlteleop (keyboard and gamepad), command relay
beebot2_bringuplaunch files, controller config, EKF config
beebot2_gazebothe simulated warehouse
beebot2_perceptionmerges two 270° LiDARs into one 360° scan
beebot2_slammapping, localisation, map scoring tools
beebot2_navigationNav2 parameters — planner, controller, costmaps
beebot2_safetyprotective and warning fields, E-stop, command priority
beebot2_bmsbattery telemetry
beebot2_interfacesthe message types a fleet system would attach to

5. Simulation is not a side project

The most important architectural decision in this build is this one:

The simulation is not a separate project. It is a digital copy of the same robot, running the same ROS 2 graph.

Concretely: sim.launch.py and robot.launch.py are deliberate mirrors of each other. Same controller configuration, same diff_drive_controller, same EKF, same command relay. Only the hardware component underneath swaps — Gazebo’s on one side, the MD200T RS485 driver on the other.

flowchart TD
  CMD["/cmd_vel"] --> MUX["twist_mux"]
  MUX --> DDC["diff_drive_controller"]
  DDC --> SEAM{{"hardware component"}}
  SEAM --> GZC["gz_ros2_control"]
  SEAM --> MOT["beebot2_motor"]
  GZC --> GZ["Gazebo"]
  MOT --> RS["RS485 → MD200T"]
  RS --> M["Motor L / Motor R"]

  classDef shared fill:#e7e5e4,stroke:#57534e,color:#1c1917
  classDef sim fill:#bfdbfe,stroke:#1d4ed8,color:#1c1917
  classDef real fill:#fde68a,stroke:#b45309,color:#1c1917
  classDef seam fill:#fef3c7,stroke:#b45309,color:#1c1917
  class CMD,MUX,DDC shared
  class GZC,GZ sim
  class MOT,RS,M real
  class SEAM seam

The payoff is that anything which behaves differently between simulation and the real machine is a real difference in the robot — not an artefact of two launch files that drifted apart. That turns simulation from a demo into a debugging tool.

6. Where the project actually stands

Being honest about this is more useful than a highlight reel.

In simulation the robot drives, maps, localises and plans. Odometry over a 4 m line comes back as 3.926 m. EKF heading error is 0.72° against 6.52° raw. The warehouse world loads in 7 seconds at 0.9× real time.

On the real robot, only the drive works. The RS485 link is confirmed, both wheels turn, and the rotation direction is established. There is no LiDAR driver, no IMU driver and no camera driver in the workspace yet — so everything downstream of them (safety fields, SLAM, localisation, Nav2) is simulation-only for now.

And navigation, even in simulation, reaches 7 of 16 benchmark goals against a

95 % target. That is not a success. It is a known blocker, and article 16 is about exactly that.

This is on purpose.

A build log that only shows the parts that worked teaches nothing. Where a real failure happens in this series, it stays in — including the debugging.

7. The road ahead — four phases

Twenty-two articles, in four blocks. Each block is named for what the robot can do once it is finished, and each one ends on a number rather than a feeling.

Phase 1 · It moves — articles 01–07

#The robot learns to…Article
2turn its wheels from a PCBench-testing the drive system
3move for the first timeDrive system on the platform
4take commands from a humanGamepad teleop
5describe its own bodyURDF
6exist in simulationGazebo
7be one robot in two worldsA single interface

Done when one /cmd_vel turns real wheels and simulated wheels through the same diff_drive_controller, with only the hardware component swapped.

Phase 2 · It senses — articles 08–10

#The robot learns to…Article
8feel its own motionIMU (3DM-GX5-AHRS)
9seeLiDAR
10know its own power stateBattery / BMS

Done when the IMU, both scanners and the BMS publish on hardware, in the right frames, at their rated rates — and the pack reading agrees with a multimeter.

Phase 3 · It navigates — articles 11–16

#The robot learns to…Article
11build a mapSLAM
12judge whether the map is goodMap evaluation
13know how it movedOdometry + EKF
14know where it isAMCL
15go somewhereNav2 planning
16fail, and be debuggedNavigation failures

Done when map median accuracy is ≤ 0.050 m, AMCL error while driving is ≤ 0.10 m, and more than 95 % of at least 16 benchmark goals are reached.

Phase 4 · It works — articles 17–22

#The robot learns to…Article
17stop before it hits somethingSafety system
18escape its own safety systemSafety failure
19charge itselfDocking
20do a jobPayload, HMI, diagnostics
21be commanded from outside ROSRobot API
22share a warehouseMulti-robot

Done when the robot runs an unattended shift — mission in, payload moved, dock, charge, repeat — with a second robot working the same map.

The word “phase” appears inside later articles too, as Phase 5, Phase 6, Phase 8. Those are the workspace’s development phases — the codebase roadmap — not these four. These four group the reading.

8. The finish line

All four phases point at one target:

BEEBOT2 accepts a mission from a system that has never heard of ROS 2, drives to a point in a warehouse it mapped itself, moves a payload, returns to its dock and charges — unattended, repeatably, on real hardware, while a second robot works the same aisles.

Repeatably and on real hardware are the load-bearing words, and section 6 already showed how far away both are. Stated as things that can be checked:

The targetThresholdToday
knows the buildingmap median accuracy ≤ 0.050 m0.150 m, simulation
knows where it isAMCL ≤ 0.10 m while driving0.184 m, simulation
gets where it is sent> 95 % of ≥ 16 goals7 / 16 = 44 %, simulation
stops before it hitsspeed → 0 inside the protective field✅ simulation
cannot be trapped by its own safetyreleases, and reverses out✅ fixed in article 18
runs a shift alonedock, charge, resume, unattended⬜ not started
takes orders from outside ROS 2mission in, status out⬜ not started
shares the floortwo robots, one map, no deadlock⬜ not started
all of it on the robotthe same numbers, off the deskdrive only

Two things gate everything else. There are no sensor drivers in the workspace, so every row marked simulation is stuck there until they exist. And localisation is the binding constraint: 0.184 m of drift while driving is a 0.4 m excursion in a 1.8 m aisle, which is the 44 % in row three.

That is the finish line, and this is the distance to it. The next twenty-one articles are the attempt.

Next

The robot cannot do anything yet — it cannot even turn a wheel. Before bolting anything to a chassis, the drive system gets tested on a bench, one layer at a time.

Next: Testing the Drive System on the Bench.