Learning Goals 5 min
Cluster E ends with the project everyone wants to build: a phone-controlled robot car. You already have all the pieces — L298N + chassis (L03-10), HC-05 + level shifter (L03-23), command protocol (L03-25). Today you combine them, install a real joystick-style app on your phone, and drive a robot around your living room from a remote. By the end of this lesson you will:
- Wire and configure a fully untethered 2WD robot: chassis + L298N + HC-05 + UNO + battery, no USB cable required.
- Use a published Android "Arduino BT Car Controller"-style app — or Adafruit Bluefruit Connect on iOS / Android — to drive with a touch joystick / d-pad.
- Add real-time speed throttling via a slider, plus an emergency stop that overrides everything, plus a heartbeat that times out the motors if the phone disconnects.
Warm-Up 10 min
The full BOM (Bill of Materials) for today:
- 2WD chassis (L03-10 build).
- 2 × DC gearmotors.
- L298N motor driver.
- HC-05 Bluetooth module (renamed and PIN-set from L03-24).
- 1 kΩ + 2 kΩ resistors for the HC-05 RX level shift.
- 4 × AA pack for motors.
- Arduino UNO + USB cable (only for upload / fallback power).
- Android phone with "Serial Bluetooth Terminal" OR "Arduino BT Car Controller" OR Adafruit Bluefruit Connect installed.
App choices
| App | OS | Style |
|---|---|---|
| Serial Bluetooth Terminal | Android | Typed commands — works with our L03-25 protocol as-is. |
| Arduino Bluetooth Controller / RC Bluetooth | Android | Joystick / d-pad / button apps that send single characters (F B L R S) on press. |
| Bluefruit Connect | Android + iOS | Adafruit's app — Controller view has D-pad. BUT requires BLE module, not HC-05. Use only with BLE peripheral from L03-26/27. |
For HC-05: use Serial Bluetooth Terminal or an Arduino-Car app. For BLE peripherals, use Bluefruit Connect or nRF Connect. Today's build assumes HC-05.
New Concept · Connection-aware safety 20 min
The new ideas vs L03-25
- Live joystick mode: continuous "F"/"B"/"L"/"R" characters arrive every ~50–100 ms while you're pressing. When you release, the app stops sending — but our existing sketch keeps the last command active forever. Solution: implement a per-command timeout. If no command for > 300 ms, stop the motors.
- Connection drop = stop. If the phone goes out of range mid-drive, the chassis would coast forever and crash. Solution: the HC-05's STATE pin (or a heartbeat timeout) detects disconnect → stop.
- Speed slider: a SPEED command updates the duty for subsequent F/B/L/R, just like L03-25. Live throttling.
The watchdog pattern
unsigned long lastCmdAt = 0;
const unsigned long CMD_TIMEOUT_MS = 300;
void loop() {
// ... existing parse + dispatch ...
// Auto-stop if no command arrived recently
if (millis() - lastCmdAt > CMD_TIMEOUT_MS) {
stopAll();
}
}
void dispatch(...) {
lastCmdAt = millis(); // reset watchdog on every received command
// ... actual dispatch ...
}So the car's default state, in the absence of input, is "stopped". Hold the d-pad arrow → app sends F at 10 Hz → watchdog stays reset → motor runs. Release → app stops sending → 300 ms later → motor stops. Natural "dead-man's switch" behaviour.
Heartbeat for connection-loss detection
Many phone-side car-controller apps DO send a heartbeat character (often H or K) so the robot knows the connection is alive. We treat any character as a heartbeat — receiving F or even an unknown char counts. The CMD_TIMEOUT_MS handles both "no input from user" and "phone disconnected" with one mechanism.
Architecture diagram
The complete sketch has three concurrent jobs in one loop():
- Bluetooth parser: drain incoming bytes, dispatch.
- Watchdog: stop motors if no command for 300 ms.
- Heartbeat output: send "# alive" every 2 s so the phone screen confirms the link.
All three non-blocking, all sharing the same loop. The pattern we've been building toward since L02-35.
Worked Example · The full chassis sketch 30 min
Step 1 — assemble
The chassis from L03-10 with the HC-05 from L03-25, all wired as before. Battery pack at the back, Arduino in the middle, HC-05 forward (the antenna trace likes a clear line-of-sight).
Step 2 — the production sketch
// L03-28 · Bluetooth-controlled robot car (HC-05 + L298N + 2WD chassis)
// Single-char drive commands: F B L R S
// Keyword config: SPEED nnn (0..255), STOP (alias for S)
// Watchdog: if no command for CMD_TIMEOUT_MS, motors stop.
// Heartbeat: '# alive' every 2 s to the phone.
#include <SoftwareSerial.h>
#include "motor.h"
const int BT_RX = 2, BT_TX = 3;
SoftwareSerial bt(BT_RX, BT_TX);
const Motor motorL = {9, 8, 5};
const Motor motorR = {7, 6, 11};
int driveDuty = 200;
String line;
const unsigned long CMD_TIMEOUT_MS = 300;
const unsigned long HEARTBEAT_MS = 2000;
unsigned long lastCmdAt = 0;
unsigned long lastHeartbeat = 0;
String lastDir = "S";
void driveForward() { motorSpeed(motorL, driveDuty); motorSpeed(motorR, driveDuty); }
void driveBackward() { motorSpeed(motorL, -driveDuty); motorSpeed(motorR, -driveDuty); }
void spinLeft() { motorSpeed(motorL, -driveDuty); motorSpeed(motorR, driveDuty); }
void spinRight() { motorSpeed(motorL, driveDuty); motorSpeed(motorR, -driveDuty); }
void stopAll() { motorSpeed(motorL, 0); motorSpeed(motorR, 0); }
void applyDir(char d) {
switch (d) {
case 'F': case 'f': driveForward(); lastDir = "F"; break;
case 'B': case 'b': driveBackward(); lastDir = "B"; break;
case 'L': case 'l': spinLeft(); lastDir = "L"; break;
case 'R': case 'r': spinRight(); lastDir = "R"; break;
case 'S': case 's':
default: stopAll(); lastDir = "S"; break;
}
}
void dispatch(const String& cmd) {
if (cmd.length() == 0) return;
lastCmdAt = millis();
if (cmd.startsWith("SPEED ")) {
int n = cmd.substring(6).toInt();
if (n >= 0 && n <= 255) {
driveDuty = n;
bt.print("OK SPEED "); bt.println(n);
} else bt.println("BAD SPEED");
return;
}
if (cmd.equalsIgnoreCase("STOP")) { stopAll(); lastDir = "S"; bt.println("OK STOP"); return; }
applyDir(cmd[0]);
bt.print("OK "); bt.println(lastDir);
}
void setup() {
Serial.begin(9600);
bt.begin(9600);
motorInit(motorL);
motorInit(motorR);
stopAll();
bt.println("# BT Car armed. F B L R S | SPEED 0..255 | STOP");
}
void loop() {
unsigned long now = millis();
// 1. Drain incoming bytes
while (bt.available()) {
char c = bt.read();
lastCmdAt = now; // any byte resets the watchdog
// Emergency-stop
if (c == 'S' || c == 's') {
stopAll();
lastDir = "S";
line = "";
bt.println("OK STOP (immediate)");
continue;
}
if (c == '\r') continue;
if (c == '\n') { dispatch(line); line = ""; continue; }
// Joystick mode — single-char direction with no terminator
if (line.length() == 0 && (c == 'F' || c == 'B' || c == 'L' || c == 'R' ||
c == 'f' || c == 'b' || c == 'l' || c == 'r')) {
applyDir(c);
continue;
}
line += c;
if (line.length() > 32) line = "";
}
// 2. Watchdog
if (now - lastCmdAt > CMD_TIMEOUT_MS && lastDir != "S") {
stopAll();
lastDir = "S";
bt.println("# watchdog -> STOP");
}
// 3. Heartbeat
if (now - lastHeartbeat >= HEARTBEAT_MS) {
lastHeartbeat = now;
bt.print("# alive speed="); bt.print(driveDuty);
bt.print(" dir="); bt.println(lastDir);
}
}Step 3 — bench test (wheels off the floor)
- Power up. Pair the phone. Open Serial Bluetooth Terminal at 9600 baud, line ending "None".
- Press "F". Both wheels spin forward.
- Wait 1 second without sending anything. Within 300 ms, the watchdog fires and wheels stop.
- Repeat with "B", "L", "R" — all should briefly spin then auto-stop.
- Type
SPEED 100\n. Then press "F". Slower spin.
Step 4 — install a controller app (Android)
On the Play Store, search "Arduino Bluetooth Controller" or "RC Bluetooth". Most send single chars on button press and resend at ~10 Hz while held. Configure: F, B, L, R, S as buttons. Connect to your HC-05. Press and hold "forward" → the chassis drives; release → it stops (watchdog).
Step 5 — drive on the floor
Set the chassis on a clear floor. USB cable disconnected. Power: the L298N's 5V regulator (with the 5V-enable jumper installed) feeds the UNO. The HC-05 takes its 5V from the same rail.
Drive around. Notice:
- Press-and-hold = drive; release = stop.
- If you walk out of range, the connection drops and the chassis stops within ~1 s (Bluetooth disconnect + watchdog).
- Type
SPEED 255for max speed bursts;SPEED 120for fine maneuvering.
Step 6 — emergency stop test
Drive forward at full speed. Tap "S" in your app. Wheels stop instantly — the 'S' bypasses the line buffer and fires immediately. Don't skip this test; you need to trust it before driving in tight spaces.
Try It Yourself 15 min
Goal: Add a horn — a buzzer on D12, sounded for 200 ms when the app sends H.
Hint
case 'H': case 'h':
tone(12, 1200, 200);
bt.println("OK HORN");
break;tone() is non-blocking — the buzzer keeps going for 200 ms while your loop continues. Make sure D12 isn't already taken by something else.
Goal: Add "gentle turn" while driving. Sending FL arcs left while moving forward; FR arcs right. Implement as a two-character recognition (first char direction, second char turn).
Hint
Track the last char received. If two chars arrive within ~50 ms, treat as a combined command. Otherwise dispatch each as a single direction. This is the "simultaneous d-pad" pattern most arcade games use.
Goal: Add an HC-SR04 ultrasonic sensor (L02-21) facing forward. While driving forward, if the distance reading is < 15 cm, override and stop. Print "# OBSTACLE" to the phone.
Hint
Read the sensor in loop() every 100 ms. Store the latest distance. In the watchdog block, also check: if driving forward AND distance < 15 cm → stop. This is the start of an "autonomous obstacle avoidance" layer — fully realised in the L03-43 Bluetooth Robot Car v2 build.
Mini-Challenge · Ship the car 10 min
- Test every safety: watchdog (release button → stops), emergency stop ('S' mid-drive → stops), disconnect (kill the app → stops).
- Tape down all wires. A flying jumper that comes loose mid-drive is the most common failure of an otherwise-working chassis.
- Power switch on the battery pack's + lead. Toggle it instead of unplugging.
- Label the buttons on your phone app for the demo — what does each one do.
- Set up a small obstacle course on the floor — a few books to weave around. Time yourself.
- Photograph and video a 30-second autonomous run + a 30-second remote-driven run.
Ship-ready test:
- Hand the phone to a classmate. With zero verbal explanation, can they drive the chassis through a doorway?
- If they stop pressing buttons or hand the phone back, does the chassis stop on its own?
- Are you confident enough in the emergency stop to drive it in a cluttered room?
If yes to all three, you've shipped Cluster E's capstone — your first wireless, untethered robot.
Recap 5 min
Cluster E ends with a real robot. The new ideas compared to L03-10's wired version were all about safety in the wireless world: a watchdog so "no command" means "stop", an emergency stop that bypasses buffering, a heartbeat for confirming the link, and connection-loss detection via the same watchdog. The bigger Cluster E lesson: Bluetooth Classic is great for Android phones streaming joystick commands; BLE is better for iOS, sensors, and battery-powered builds. We'll come back to this robot in L03-43 (Build) with PWM speed, BLE app, and obstacle avoidance. Next cluster: leave Bluetooth's 10 m range behind and step onto the internet with WiFi.
- Watchdog timer
- A piece of code that automatically stops or resets the system if it doesn't see an expected event within a deadline. For our car: if no Bluetooth byte for 300 ms, motors stop.
- Dead-man's switch
- Any control scheme where action requires continuous input. Press-and-hold drives the car; release stops it. Standard in tools, vehicles, industrial machines.
- Emergency stop
- A path that bypasses normal command processing and immediately halts actuators. Should work from any input state — half-typed buffer included.
- Heartbeat
- Periodic message from the device confirming "I'm alive and you're connected". Useful for the operator who can't see the device.
- Joystick mode
- Single-character commands arriving at high rate (~10/s) for as long as the user holds a button. Contrasts with line-buffered "type a verb + Enter" mode.
- Connection-loss detection
- The mechanism that notices the phone has disconnected. Cheapest path: same watchdog as the no-input one (no bytes for N ms = disconnect = stop).
- Bench test vs floor test
- Bench test = chassis on a desk, wheels free, you can grab it. Floor test = chassis on the floor, can crash. Always bench-test new code first.
- Ship-ready
- Another person can use the device without your instructions. Cluster E's capstone bar — same as Cluster A (turntable) and Cluster B (rover).
Homework 5 min
- Drive the car around. Get used to the controls. Note any wished-for features.
- Save as
bt-car.ino. We extend this in L03-43 (Build). - Record a 60-second video: includes drive, stop, obstacle-avoidance (if you did the stretch), and a final emergency-stop demo.
- Read ahead to ARD-L03-29 (WiFi: ESP8266 vs ESP32). Bring tomorrow: a NodeMCU ESP8266 or an ESP32 DevKit board (small blue boards with a microUSB connector and a WiFi antenna). Don't wire anything yet — just bring it.
Bring back next class:
- Driving car (still wired, ready for L03-43 build).
- 60-second video.
- ESP8266 / ESP32 board for the start of Cluster F.