I'm Building an AMR from Zero
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):
| AGV | AMR | |
|---|---|---|
| How it follows a route | fixed infrastructure — magnetic tape, buried wire, reflectors | a map plus onboard sensing |
| Blocked path | stops and waits | plans around it |
| Changing the route | re-lay the tape | edit the map |
| What it needs from the building | a lot | almost 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.

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.
| BEEBOT2 | AMR | |
|---|---|---|
| Envelope | 0.50 × 0.53 m | 1.14 × 0.94 m |
| Chassis | 0.43 × 0.43 × 0.40 m | 1.00 × 0.80 × 0.38 m |
| Mass | 40 kg | 300 kg |
| Wheels | ⌀0.13 m, track 0.48 m | ⌀0.22 m, track 0.663 m |
| Scanners | 360° roof + 180° front safety | 2 × 270° corner, merged to 360° |
| Swept diameter | 0.729 m | 1.477 m |
| Lift | no | yes |
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

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

MDROBOT MD200T
2 channels, 12–48 V, 10 A each
Actuators

2 × BLDC hub motors
ZLLG65ASM250-L — ⌀0.13 m, 6.5 N·m
-
Lithium pack + DALY BMS → Distribution + E-stop —
24 V -
Lithium pack + DALY BMS → PC — Ubuntu + ROS 2 Jazzy —
BMS · USB, 9600 baud -
Distribution + E-stop → PC — Ubuntu + ROS 2 Jazzy —
regulated DC -
Distribution + E-stop → MDROBOT MD200T —
24 V drive rail -
PC — Ubuntu + ROS 2 Jazzy → MDROBOT MD200T —
USB ↔ RS485 -
LiDAR × 2 → PC — Ubuntu + ROS 2 Jazzy —
/scan · /scan_front -
IMU — 3DM-GX5-AHRS → PC — Ubuntu + ROS 2 Jazzy —
/imu/data -
MDROBOT MD200T → 2 × BLDC hub motors —
3-phase, 2 channels -
2 × BLDC hub motors → MDROBOT MD200T —
encoders / hall
- DC power
- RS485 — the drive bus
- USB / serial — everything else
- Motor phase (3-ph)
- Encoder / Hall
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
| Sensor | What it gives the robot |
|---|---|
| LiDAR | distance to everything around it, ~15 times a second |
| IMU — MicroStrain 3DM-GX5-AHRS | acceleration and rotation rate — how it is actually moving |
| Wheel encoders | how far each wheel turned |
| RGBD camera | colour + depth, for higher-level perception later |
| Bumper | physical 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:
| Layer | What it does |
|---|---|
ros2_control | the seam between “wheel velocity” and “bytes on a wire” |
diff_drive_controller | turns 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_toolbox | builds a map from LiDAR while the robot drives |
| AMCL | finds the robot’s position on a map it already has |
| Nav2 | plans a path to a goal and follows it |
twist_mux | decides whose velocity command wins when several are talking at once |
Split across packages, that is:
| Package | Contents |
|---|---|
beebot2_description | URDF/xacro, meshes, geometry regression tests |
beebot2_motor | MD200T drive over RS485, plus a bring-up probe tool |
beebot2_control | teleop (keyboard and gamepad), command relay |
beebot2_bringup | launch files, controller config, EKF config |
beebot2_gazebo | the simulated warehouse |
beebot2_perception | merges two 270° LiDARs into one 360° scan |
beebot2_slam | mapping, localisation, map scoring tools |
beebot2_navigation | Nav2 parameters — planner, controller, costmaps |
beebot2_safety | protective and warning fields, E-stop, command priority |
beebot2_bms | battery telemetry |
beebot2_interfaces | the 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 seamThe 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 |
|---|---|---|
| 2 | turn its wheels from a PC | Bench-testing the drive system |
| 3 | move for the first time | Drive system on the platform |
| 4 | take commands from a human | Gamepad teleop |
| 5 | describe its own body | URDF |
| 6 | exist in simulation | Gazebo |
| 7 | be one robot in two worlds | A 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 |
|---|---|---|
| 8 | feel its own motion | IMU (3DM-GX5-AHRS) |
| 9 | see | LiDAR |
| 10 | know its own power state | Battery / 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 |
|---|---|---|
| 11 | build a map | SLAM |
| 12 | judge whether the map is good | Map evaluation |
| 13 | know how it moved | Odometry + EKF |
| 14 | know where it is | AMCL |
| 15 | go somewhere | Nav2 planning |
| 16 | fail, and be debugged | Navigation 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 |
|---|---|---|
| 17 | stop before it hits something | Safety system |
| 18 | escape its own safety system | Safety failure |
| 19 | charge itself | Docking |
| 20 | do a job | Payload, HMI, diagnostics |
| 21 | be commanded from outside ROS | Robot API |
| 22 | share a warehouse | Multi-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 target | Threshold | Today |
|---|---|---|
| knows the building | map median accuracy ≤ 0.050 m | 0.150 m, simulation |
| knows where it is | AMCL ≤ 0.10 m while driving | 0.184 m, simulation |
| gets where it is sent | > 95 % of ≥ 16 goals | 7 / 16 = 44 %, simulation |
| stops before it hits | speed → 0 inside the protective field | ✅ simulation |
| cannot be trapped by its own safety | releases, and reverses out | ✅ fixed in article 18 |
| runs a shift alone | dock, charge, resume, unattended | ⬜ not started |
| takes orders from outside ROS 2 | mission in, status out | ⬜ not started |
| shares the floor | two robots, one map, no deadlock | ⬜ not started |
| all of it on the robot | the same numbers, off the desk | drive 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.