Tutorial · Beginner · 1 hour

Control a Robot Over Bluetooth With an HC-05 Module

Wire an HC-05 to an Arduino, pair it with your phone, and design a serial command protocol that keeps a robot responsive and safe when the link drops.

Published

Introduction

The HC-05 is the cheapest way to make a robot wireless. It is a Bluetooth serial bridge: whatever your phone sends arrives on the Arduino’s serial port as ordinary bytes, and whatever the Arduino prints goes back to the phone. Nothing in your sketch has to know Bluetooth exists.

That simplicity is the appeal, and it is also the trap. A radio link is not a wire — it drops, it lags, and it disconnects while your robot is still driving. Most of this tutorial is about the difference.

Wiring, and the mistake almost everyone makes

The HC-05 module runs its logic at 3.3 V, even though the breakout board accepts 5 V on VCC. The RX pin is not 5 V tolerant.

HC-05 Arduino Uno / Nano Notes
VCC 5V The board’s regulator handles this
GND GND Common ground with everything else
TXD D10 (soft RX) 3.3 V out is read fine as logic HIGH by a 5 V input
RXD D11 (soft TX) through a divider 5 V straight in will damage it over time
EN / KEY D9 (optional) Pull HIGH at power-up to enter AT command mode

Drop the Arduino’s 5 V transmit line to 3.3 V with a two-resistor divider — 2 kΩ from the Arduino pin to the HC-05 RXD, and 3.3 kΩ from that junction to ground. It costs two components and prevents a failure that is genuinely maddening to diagnose, because a module damaged this way often keeps half working: it pairs, it transmits, and it silently ignores or corrupts what you send it.

A wiring diagram. An Arduino Uno or Nano connects its TXD line directly to pin D10 of an HC-05 module, labelled as safe because 3.3 volts out reads HIGH on a 5 volt input. The return path from pin D11 to the module's RXD pin routes through a 2 kilohm and 3.3 kilohm voltage divider, with a warning that 5 volts straight into RXD damages the module partially so it still pairs but corrupts what you send it.
Three of the four wires are trivial. The fourth needs a divider, because 5 V into the module's RXD damages it slowly rather than obviously. Download SVG

Use SoftwareSerial, not pins 0 and 1. The hardware serial port is shared with USB, so a module wired there fights the serial monitor and blocks uploads.

#include <SoftwareSerial.h>
SoftwareSerial bt(10, 11);   // RX, TX

void setup() {
  Serial.begin(115200);      // USB, for debugging
  bt.begin(9600);            // HC-05 default baud
}

Pairing

Power the robot, and the module’s LED blinks quickly — that is “waiting to pair”. On your phone, pair with HC-05 using the code 1234 or 0000.

Pairing is not connecting. The link only opens when an app on the phone actually opens the serial port. Any Bluetooth serial terminal will do to start with; the module’s LED slows to a double-blink once a connection is live.

Designing the command protocol

The obvious first protocol is one character per command, and it works for about ten minutes:

if (bt.available()) {
  char c = bt.read();
  if (c == 'F') forward();
  else if (c == 'B') back();
  else if (c == 'S') stop();
}

Two things go wrong with it.

It has no speed. Every command is full throttle, so the robot is either stationary or charging. Send a value with the command instead — one letter and a number, terminated by a newline, which is trivial to parse and trivial to type by hand while debugging:

// "F180\n" — forward at 180 of 255
char buffer[16];
if (bt.available()) {
  int n = bt.readBytesUntil('\n', buffer, sizeof(buffer) - 1);
  buffer[n] = '\0';
  char cmd = buffer[0];
  int value = atoi(buffer + 1);
  ...
}

It assumes the message arrives whole. bt.available() becoming non-zero means some bytes are here, not all of them. Reading one character per loop and acting on it immediately will act on half a command. Reading until the newline is what makes the parse reliable.

A wired robot stops when you let go. A wireless robot keeps doing the last thing you told it — which, if the last thing was “forward at full speed” and you have just walked out of range, is a robot going under the sofa at full tilt.

A timing diagram. A row of command pulses arrives every 100 milliseconds while a button is held, then stops at a dashed line marked signal lost. Below, a bar showing no timeout stays red and driving out of control indefinitely after the signal is lost. A second bar with a 400 millisecond deadman timeout turns to stopped shortly after the last command.
The command stream stops when the link drops, not when the user lets go — which is why the robot has to time out on its own. Download SVG

Every wireless robot needs a deadman timeout. The rule is simple: if no command has arrived recently, stop.

unsigned long lastCommandAt = 0;
const unsigned long TIMEOUT_MS = 500;

void loop() {
  if (readCommand()) lastCommandAt = millis();

  // Nothing heard recently? Assume the link is gone.
  if (millis() - lastCommandAt > TIMEOUT_MS) stop();
}

For this to help, the controller has to keep talking. Have the phone send the current command repeatedly — every 100 ms or so while a button is held — rather than once on press and once on release. Then “button released” and “phone went out of range” produce the same safe outcome, and you never depend on a release message that may never arrive.

Use millis() for this, never delay(). A delay(200) in the loop is 200 ms during which no command is read, the timeout is not checked, and the robot is deaf.

AT command mode

To rename the module or change its baud rate, hold EN/KEY HIGH while powering up. The LED blinks slowly — about once every two seconds — which is how you know you are in command mode rather than data mode.

AT              → OK
AT+NAME=MyRobot → OK
AT+PSWD="4821"  → OK
AT+UART=38400,0,0

Commands must be sent at 38400 baud in AT mode regardless of the data-mode baud rate, and most firmware requires both carriage return and newline. If AT returns nothing, that mismatch is almost always why.

Raising the data baud rate is worth doing if you are streaming telemetry back, but leave it at 9600 while you are getting the protocol right — a faster link mostly makes framing bugs harder to see.

Verify before you drive

Three checks, in this order:

  1. Echo test. Have the Arduino print back whatever it receives. If a typed line comes back intact, the wiring and baud rate are right.
  2. Parse test. Print the parsed command letter and value. Confirm F180 gives F and 180, not F and 1.
  3. Timeout test. Send a forward command, then close the app or walk away. The robot must stop within your timeout. If it does not, fix that before putting wheels on the floor.

Once the link is reliable, the same command protocol drops straight into a robot with autonomy: build the wireless layer in the phone-controlled robot project, or move up to the ESP32, which serves a control page over Wi-Fi and needs no app at all.

Explore the graph

Part of these builds

Projects and learning paths that include this tutorial.

Further reading

References