Detailed content
🤝 Split the work: Pi thinks, ESP32 acts
The Raspberry Pi runs Linux, which is great for cameras, OpenCV, and YOLO26, but it can’t guarantee timing: a background update can delay your program by hundreds of milliseconds. The ESP32 runs a simple, predictable loop, ideal for generating PWM, reading encoders, and stopping the motors at the right time. The classic split is: the Pi sends intentions (“go 60% to the left”) a few dozen times per second, and the ESP32 carries them out and keeps the robot safe.
How to read this map: The purple arrow carries intentions from the Pi to the ESP32; the dashed cyan arrow brings back confirmation. The golden rule is in the box on the right: whoever controls the motor is the one who decides to stop. If the Pi freezes, the ESP32 doesn't wait around.
✓ Do
- ✓The Pi sends the desired speed; the ESP32 limits and applies it.
- ✓The ESP32 stops the motors if commands stop arriving.
- ✓Collision sensor connected directly to the ESP32, which brakes without asking.
✗ Don't
- ✗Generate the motor PWM in Python on the Pi.
- ✗Send “turn on the motor” once and trust that an “turn it off” command will arrive.
- ✗Power the Pi from the same line as the motors, without a regulator.
Why learn this
This division appears in almost every serious robot, from a vacuum cleaner to a warehouse robot. In Track 4 it stays the same: the Pi runs ROS 2 and the ESP32 communicates with it through micro-ROS. Only the protocol changes.
Key concepts
🧵 UART between Pi and ESP32 (3.3 V levels)
🌱 New here?
UART is the serial port: one wire to send (TX), one to receive (RX) and GND as the reference. Both sides agree on the speed in baud (bits per second); we use 115200. One side's TX connects to the other side's RX, crossed over. The Serial Monitor you've been using since module 1.3 is a UART connection through the USB cable.
The Pi 5 and ESP32 use 3.3 V logic, so the wires connect directly, with no converter. On the Pi, use physical pins 8 (GPIO14, TXD) and 10 (GPIO15, RXD); on the ESP32, use UART2 on GPIO16 (RX) and GPIO17 (TX), leaving UART0 free for USB and boot messages. Another, simpler option is to connect the ESP32 to the Pi with its USB cable (it appears as /dev/ttyACM0 or /dev/ttyUSB0); the protocol and code are the same.
How to read this map: Each line is a wire. The purple wire carries what the Pi transmits to the ESP32's input; the cyan wire carries data in the opposite direction. The dashed GND wire is required: without it, the two sides don't agree on what 0 V means, and characters arrive garbled.
Step by step on the Pi
Enable the serial port
sudo raspi-config → Interface Options → Serial Port. “Login shell over serial?” No. “Serial port hardware enabled?” Yes. Restart.
Give your user permission
sudo usermod -aG dialout $USER, then log out and back in. Check that ls -l /dev/serial0 exists.
Loopback test
Before connecting the ESP32, place a jumper between pins 8 and 10 on the Pi and run the test below. Anything sent through TX should come back through RX.
python3 -m venv --system-site-packages ~/robo-venv # only if you haven't already created it in module 3.1
source ~/robo-venv/bin/activate
pip install pyserial
python -c "import serial; s=serial.Serial('/dev/serial0',115200,timeout=1); s.write(b'ola\n'); print(s.readline())"
How to check: With the jumper, the output is b'ola\n'. Without the jumper, it prints b'' after 1 second. “Permission denied” means step 2 hasn't taken effect yet: log out and back in to your session.
Why learn this
UART is the simplest and easiest-to-debug way for two boards to communicate: you can read everything with a Serial Monitor. Testing the Pi on its own with loopback helps you tell a wiring problem from a code problem.
Key concepts
📨 Simple message protocol
Bytes arrive over the wire as a stream; the protocol is the agreement that tells you where each message starts and ends and how to tell whether it arrived intact. We'll use one line of text per message, like GPS (NMEA): it starts with $, contains the command, a *, two checksum digits, and ends with a newline. Text is readable in the Serial Monitor and easy to generate in any language.
How to read this map: Only the purple body carries information; the cyan boxes are framing. The Pi calculates the checksum, sends it along, and the ESP32 recalculates it. If the two numbers don't match, the ESP32 returns an error and ignores the command.
| Message | Direction | Meaning |
|---|---|---|
| $M,esq,dir*HH | Pi → ESP32 | speed for each side, from −255 to 255 |
| $S*53 | Pi → ESP32 | stop now |
| $P*50 | Pi → ESP32 | ping: are you there? |
| $OK,M*65 | ESP32 → Pi | command accepted |
| $ERR,motivo*HH | ESP32 → Pi | CHK, FORMATO, ARG, CMD, LONGA or TIMEOUT |
✓ Short text line
- ✓You can read and type it in the Serial Monitor.
- ✓Fits in a 40-byte buffer, with no extra library.
- ✓Short JSON ({"m":[120,-80]}) also works, with ArduinoJson.
✗ Avoid
- ✗Message without an end marker: you never know where it ends.
- ✗Accepting commands without a checksum on a robot with a motor.
- ✗Long, frequent messages: 115200 baud is about 11,000 bytes per second.
🌱 New here?
Checksum is a number calculated from the message and sent along with it. The XOR compares bits: it returns 1 when they differ and 0 when they're the same. XOR-ing all the bytes means any changed byte changes the result. It doesn't protect against everything, but it catches most wire noise.
Why learn this
A motor that receives “M,1200” instead of “M,120” because of a duplicated byte means a robot crashing into a wall. Framing, a checksum, and explicit responses turn a noisy wire into a reliable channel, and the same idea is used in industrial protocols.
Key concepts
🛡️ ESP32 side: safely receiving commands
The sketch joins characters until it reaches a newline, discards lines that are too long, checks the frame and checksum, limits the speeds, and applies them to the H-bridge TB6612FNG of module 1.5 with the PWM 3.x API. The most important part is the last lines of the loop(): if no valid motor command arrives within 300 ms, the motors stop.
Goal: ESP32 that follows the protocol from topic 3 and stops on its own when no commands arrive. Pins for an ESP32 DevKit; on an ESP32-S3, replace them with free pins on your board.
// Receives commands from the Raspberry Pi over UART2 and moves the motors (ESP32, esp32 3.x package)
// Protocol: "$M,esq,dir*HH\n" "$S*53\n" "$P*50\n" (HH = XOR of the body in hexadecimal)
const int PINO_RX = 16, PINO_TX = 17; // RX (GPIO16) receives from Pi pin 8; TX (GPIO17) goes to pin 10
const int PWMA = 25, AIN1 = 26, AIN2 = 27; // TB6612FNG: left motor
const int PWMB = 33, BIN1 = 32, BIN2 = 4; // right motor (same pins as the rover in Trail 1)
const int STBY = 13; // HIGH = H-bridge enabled
const unsigned long TIMEOUT_MS = 300; // no motor command for 300 ms: stop
const int MAX_LINHA = 40;
char linha[MAX_LINHA + 1];
int tam = 0;
bool descartando = false, andando = false;
unsigned long ultimoComando = 0;
uint8_t xorDe(const char *s, int n) { uint8_t x = 0; for (int i = 0; i < n; i++) x ^= (uint8_t)s[i]; return x; }
void responde(const char *corpo) { // sends "$corpo*HH\n" to the Pi
char buf[56];
snprintf(buf, sizeof(buf), "$%s*%02X\n", corpo, xorDe(corpo, strlen(corpo)));
Serial2.print(buf);
}
void motor(int pwm, int in1, int in2, int v) {
v = constrain(v, -255, 255);
digitalWrite(in1, v > 0 ? HIGH : LOW);
digitalWrite(in2, v < 0 ? HIGH : LOW);
ledcWrite(pwm, abs(v)); // API 3.x: ledcWrite takes the pin
}
void mover(int esq, int dir) { motor(PWMA, AIN1, AIN2, esq); motor(PWMB, BIN1, BIN2, dir); }
void parar() { mover(0, 0); andando = false; }
void trata(char *l) { // l = "$M,120,-80*5B" (without the \n)
char *estrela = strrchr(l, '*');
if (l[0] != '$' || estrela == NULL || strlen(estrela) != 3) { responde("ERR,FORMATO"); return; }
char *corpo = l + 1;
int n = estrela - corpo;
if (xorDe(corpo, n) != (uint8_t)strtol(estrela + 1, NULL, 16)) { responde("ERR,CHK"); return; }
*estrela = '\0'; // cuts off the checksum: only the body remains
if (corpo[0] == 'M' && corpo[1] == ',') {
int esq, dir;
if (sscanf(corpo + 2, "%d,%d", &esq, &dir) != 2) { responde("ERR,ARG"); return; }
mover(esq, dir); andando = true; ultimoComando = millis();
responde("OK,M");
} else if (strcmp(corpo, "S") == 0) { parar(); responde("OK,S"); }
else if (strcmp(corpo, "P") == 0) { responde("OK,P"); }
else responde("ERR,CMD");
}
void setup() {
Serial.begin(115200); // USB: messages for you
Serial2.begin(115200, SERIAL_8N1, PINO_RX, PINO_TX); // UART2 with explicit pins
int saidas[] = { AIN1, AIN2, BIN1, BIN2, STBY };
for (int p : saidas) pinMode(p, OUTPUT);
ledcAttach(PWMA, 20000, 8); // 20 kHz, 8 bits (0 to 255)
ledcAttach(PWMB, 20000, 8);
parar();
digitalWrite(STBY, HIGH);
responde("OK,BOOT"); // tells the Pi it (re)started
Serial.println("Ready: waiting for commands on UART2");
}
void loop() {
while (Serial2.available()) {
char c = Serial2.read();
if (c == '\r') continue;
if (c == '\n') {
linha[tam] = '\0';
if (descartando) responde("ERR,LONGA"); else if (tam > 0) trata(linha);
tam = 0; descartando = false;
} else if (tam < MAX_LINHA) linha[tam++] = c;
else descartando = true; // line is too long: discard everything until \n
}
if (andando && millis() - ultimoComando > TIMEOUT_MS) { // safe stop
parar();
responde("ERR,TIMEOUT");
Serial.println("No commands: motors stopped");
}
}
How to check (before connecting to the Pi)
- ✓Wheels off the ground. The Serial Monitor (USB) shows "Ready: waiting for commands on UART2".
- ✓With a 3.3 V USB-to-serial adapter connected to GPIO16/17, send $M,150,150*4D: the wheels turn and, 300 ms later, stop on their own with "No commands".
- ✗Send $M,150,150*00: the response is $ERR,CHK and nothing moves.
Why learn this
This sketch is the robot's "firmware" for the rest of the trail. Everything decided on the Pi, including by AI, passes through this filter: only commands with the right format, the right checksum, and values within range get through. That's how you prevent a model that makes mistakes from turning into an out-of-control motor.
Key concepts
🐍 Pi side: Python with pyserial
On the Pi, a class Link hides the details: it builds the frame, calculates the checksum, sends it, reads the response, and checks its checksum. The demo at the end of the file sends the robot forward, turns, and reverses, repeating the command 20 times per second, which keeps the ESP32 moving. In module 3.6, the object follower imports this same class.
Goal: control the ESP32 from the Pi using the protocol from topic 3. Save it as link_esp32.py and run it inside the virtual environment with python link_esp32.py. If you use the USB cable instead of the wires, change the port to /dev/ttyACM0 (or /dev/ttyUSB0).
#!/usr/bin/env python3
"""Serial link Pi -> ESP32 (module 3.5 protocol). Requires: pip install pyserial"""
import time
import serial
PORTA = "/dev/serial0" # UART on pins 8/10; use "/dev/ttyACM0" with the USB cable
BAUD = 115200
def checksum(corpo):
x = 0
for b in corpo.encode("ascii"):
x ^= b
return f"{x:02X}"
class Link:
def __init__(self, porta=PORTA, baud=BAUD):
self.porta, self.baud, self.ser = porta, baud, None
self.abrir()
def abrir(self):
self.ser = serial.Serial(self.porta, self.baud, timeout=0.05)
self.ser.reset_input_buffer()
def ler_resposta(self):
bruto = self.ser.readline().decode("ascii", errors="replace").strip()
if not bruto.startswith("$") or "*" not in bruto:
return None
corpo, chk = bruto[1:].rsplit("*", 1)
return corpo if chk.upper() == checksum(corpo) else None
def enviar(self, corpo):
try:
self.ser.write(f"${corpo}*{checksum(corpo)}\n".encode("ascii"))
return self.ler_resposta()
except (serial.SerialException, OSError) as e:
print("link dropped:", e, "- trying to reopen")
time.sleep(0.5)
try:
self.ser.close()
self.abrir()
except (serial.SerialException, OSError):
pass
return None
def motores(self, esq, drt):
esq = max(-255, min(255, int(esq)))
drt = max(-255, min(255, int(drt)))
return self.enviar(f"M,{esq},{drt}")
def parar(self):
return self.enviar("S")
def ping(self):
return self.enviar("P")
def fechar(self):
try:
self.parar()
finally:
self.ser.close()
if __name__ == "__main__":
link = Link()
try:
print("ping ->", link.ping())
for nome, esq, drt, segundos in [("frente", 150, 150, 1.0), ("gira", 120, -120, 0.8), ("re", -150, -150, 1.0)]:
print(nome)
fim = time.monotonic() + segundos
while time.monotonic() < fim:
r = link.motores(esq, drt) # repeating at 20 Hz keeps the ESP32 moving
if r != "OK,M":
print("resposta:", r)
time.sleep(0.05)
print("stop ->", link.parar())
except KeyboardInterrupt:
print("interrompido")
finally:
link.fechar() # always send "S" on exit
How to check
- ✓The terminal displays ping -> OK,P, then "forward", "turn", "reverse" and stop -> OK,S.
- ✓With the wheels off the ground, they follow the sequence and stop at the end.
- ✗ping -> None? Check that TX/RX are crossed, GND is shared, and the ESP32 is running the sketch from topic 4.
💡 Test without a robot
Even without motors connected, you can still test everything: with the ESP32 alone on the breadboard, the USB Serial Monitor shows "No commands: motors stopped" when you interrupt Python with Ctrl+C in the middle of a movement.
Why learn this
Keeping "how to talk to the board" in a small class leaves the rest of the code clean: the object follower only calls link.motores(esq, dir). E o try/finally ensures that "S" is sent on exit, even when the program crashes.
Key concepts
🚨 Failures: lost link, reset, safe stop
Think of everything that could go wrong: a wire comes loose, Python freezes, the Pi restarts, or the ESP32 restarts because the voltage drops when the motor starts. In every case, the right response is the same: the robot stays stopped until it receives a valid command again. The ESP32 already does this with the timeout and starts stopped when powered on; the Pi detects a reset from the OK,BOOT message or from consecutive None responses.
How to read this map: each box is an ESP32 state, and each arrow shows what causes the state to change. Notice that there is only one way to reach MOVING (a valid M command) and two ways to return to STOPPED. The initial state, after any reset, is always STOPPED.
✓ Deliberate test
- ✓Disconnect the Pi's TX wire while the robot is moving (wheels off the ground): it stops within 300 ms.
- ✓Kill Python with kill -9: finally won't run, but the timeout stops it.
- ✓Press the ESP32's EN button: it returns to the stopped state and reports OK,BOOT.
- ✓Have a physical switch that cuts power to the motors.
✗ Pitfalls
- ✗Long timeout (2 s) on a fast robot: it travels meters "blind".
- ✗Ping renewing the motor timeout: if the Pi freezes midway, the robot would keep moving.
- ✗Automatically resume the last command after a reset.
- ✗Motor and ESP32 on the same battery with no capacitor: each startup resets the board.
Why learn this
A robot that works when everything goes right is half the job; the other half is making sure it behaves well when something goes wrong. This pattern (a command with an expiration time, a safe initial state, an independent physical stop) is the same one used in industrial robots and returns in Track 5 with the robotic arm.
Key concepts
✅ Module summary
To go further
- Arduino-ESP32: Serial (UART) — Serial2.begin pins and parameters
- pyserial documentation — Serial, timeout, readline
- Raspberry Pi UARTs — serial0, raspi-config and config.txt
- pinout.xyz — Pi 40-pin map
Next module
3.6 · Project: object-following robot. Camera, detection, error and PID, using this link to move the wheels.