Lesson 05: Making Things Move -- Motors, Drivers, and PWM
Robotics from Scratch . Lesson 05 . Beginner . ~25 min read . Updated April 2026
Your LED Was Just the Beginning
In Lesson 04, you built a webpage that toggles an LED. Press a button on your phone, and the ESP32 turns the LED on or off. The circuit works. The web server works. You have a remote-controlled light.
Now: what if instead of toggling an LED, you toggled something that actually moves?
That is what this lesson is about. By the end, your ESP32 will spin a DC motor -- and you will control the speed from a webpage. Same architecture. More power.
What a DC Motor Actually Is
A DC motor is a tube with copper wire wrapped around it and a permanent magnet on the inside. When you put electricity through the wire, it becomes an electromagnet. That electromagnet pushes against the permanent magnet. The tube spins. That is the whole story.
More voltage = stronger magnetic field = faster spin.
Reverse the polarity -- flip the + and - -- and the motor spins the other direction.
That is it. No code, no magic. Physics.
Why You Cannot Drive a Motor from a GPIO Pin
GPIO pins on the ESP32 can supply a maximum of about 40mA. A typical small DC motor -- the kind you would use on a robot chassis -- needs 100mA to 300mA to spin properly. The GPIO pin cannot provide that much current. If you try, the ESP32 will either throttle the motor, behave unpredictably, or potentially damage the chip.
The GPIO pin is the brains. It sends signals. You need a muscle to actually drive the motor.
That is what a motor driver does.
The L298N Motor Driver Board
The L298N is a dual H-bridge motor driver. It takes two things:
Input signals -- low-current digital signals from your ESP32 GPIO pins. These tell it which direction to send power, and whether to go fast, slow, or stop.
External power -- a battery pack or power supply that provides the current the motor actually needs.
The L298N then routes that external power to the motor in the right direction. Your ESP32 GPIO pins never touch the motor's power draw. They just whisper instructions.
The H-bridge part means it can reverse the polarity going to the motor -- which is how it spins the motor in both directions.
Here is the pin layout:
L298N Module
+12V input (from battery pack)
GND (shared with ESP32 ground)
+5V output (if jumper installed -- do not use this)
IN1 (GPIO signal -- direction pin 1)
IN2 (GPIO signal -- direction pin 2)
EN1 (PWM speed control -- connect to GPIO for PWM)
OUT1 (to motor lead 1)
OUT2 (to motor lead 2)
The key two pins for direction are IN1 and IN2. The EN1 pin is where PWM speed control goes.
Wiring -- ESP32 to L298N to Motor
Before you wire, gather your parts:
- ESP32-C3 on breadboard
- L298N motor driver module
- 4x AA battery pack (or a 9V battery) for external motor power
- Small DC motor (3V-6V)
- Hook-up wire
Wire it like this:
ESP32-C3 L298N Motor Driver Battery Pack
-------- -------------------- -------------
[GPIO4] -------------------- IN1
[GPIO5] -------------------- IN2
[GPIO6] -------------------- EN1 / PWM
[GND] -------------------- GND (must be shared!)
+12V --- [red wire] ---+
GND --- [blk wire] ---+
|
| OUT1 --- [motor lead 1]
| OUT2 --- [motor lead 2]
|
The full ASCII wiring diagram:
+------------------------------------------------------------------+
| |
| ESP32-C3 ~~~~~ USB Cable ~~~~~ Laptop (for code + serial) |
| |
| [GPIO4] ---------------- IN1 (L298N) |
| [GPIO5] ---------------- IN2 (L298N) |
| [GPIO6] ---------------- EN1 / PWM (L298N) |
| [GND] ---------------- GND (L298N) <-- shared ground! |
| |
| L298N Motor Driver |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| | +12V | | GND | | IN1 | | IN2 | |
| | (battery) | | (shared) | | (GPIO4) | | (GPIO5) | |
| +-----------+ +-----------+ +-----------+ +-----------+ |
| | |
| +-----------+ +-----------+ +-----------+ | |
| | EN1/PWM | | OUT1 | | OUT2 | | |
| | (GPIO6) | | (motor) | | (motor) | | |
| +-----------+ +-----------+ +-----------+ | |
| | | | |
| +----[ Motor ]--+ | |
+------------------------------------------------------------------+
Battery Pack
+-----------+ +-----------+
| [+] 4xAA | | [-] |
+-----------+ +-----------+
Important notes:
Shared ground: The GND of the ESP32, the GND of the L298N, and the negative terminal of the battery must all be connected together. Without this shared ground, the signal between the ESP32 and L298N has no reference point and the L298N will not respond.
External power only: The L298N has a +5V output pin with a jumper. Do not use this to power the ESP32. Use the jumper-less configuration shown here -- the ESP32 is powered over USB, and the L298N draws motor power from the battery pack.
Battery voltage: A fresh 4xAA pack (about 6V) or a 9V battery works well for small motors. Check your motor's rating -- if it says 3V-6V, use 4xAA. If it says 6V-12V, use a 9V or a 2S LiPo.
Amazon has L298N kits that include the board, motor, and battery holder for around $10-$15:
L298N motor driver kit on Amazon
Affiliate link
PWM -- Pulse Width Modulation
Before we write the code, you need to understand PWM. It stands for Pulse Width Modulation, and it is the key to controlling motor speed.
A GPIO pin can only output 0V or 3.3V. It cannot output 1.5V or 2.2V. So how do you make a motor spin at half speed?
You flip the pin on and off very fast.
PWM works by rapidly toggling the GPIO pin between on (3.3V) and off (0V). If you do it fast enough, the motor cannot keep up with the individual pulses -- it averages them. The percentage of time the signal is ON versus OFF is called the duty cycle.
- 100% duty cycle = full voltage = full speed
- 50% duty cycle = half the time on = half speed
- 0% duty cycle = always off = stopped
The frequency (how fast the pulses happen) also matters. Too slow and the motor stutters. Too fast and the motor cannot respond. For most small DC motors, a frequency of 500Hz-1000Hz works well.
MicroPython makes this straightforward. You create a PWM object on a pin, set the frequency, and set the duty cycle.
The Code -- Motor Speed Control with PWM
Here is the full code. Save it as main.py on your ESP32 and run it:
'
import network
import socket
import time
from machine import Pin, PWM
--- WiFi Setup ---
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect('YOUR_SSID', 'YOUR_PASSWORD')
while not wlan.isconnected():
time.sleep(0.5)
print('Connected! IP:', wlan.ifconfig()[0])
--- Hardware Setup ---
Motor direction pins (just for on/off for now)
in1 = Pin(4, Pin.OUT)
in2 = Pin(5, Pin.OUT)
Motor speed control (PWM on enable pin)
The EN1 pin on the L298N controls speed when connected to GPIO6
in_pwm = Pin(6, Pin.OUT)
Create PWM object on the speed control pin
Start at 0 speed (duty 0)
motor_pwm = PWM(in_pwm)
motor_pwm.freq(500) # 500Hz is a good frequency for small motors
motor_pwm.duty_u16(0) # duty_u16 takes 0-65535 (16-bit resolution)
# 0 = stopped, 65535 = full speed
Helper to set motor speed (0.0 to 1.0)
def set_speed(speed):
# speed is 0.0 (stopped) to 1.0 (full speed)
duty = int(speed * 65535)
motor_pwm.duty_u16(duty)
Helper to set motor direction
dir: True = forward, False = reverse
def set_direction(dir):
if dir:
in1.value(1)
in2.value(0)
else:
in1.value(0)
in2.value(1)
Start with motor stopped
set_speed(0.0)
set_direction(True)
--- HTML Page ---
The webpage shows a slider to control motor speed from 0-100%
and a direction toggle
def webpage(speed, direction):
dir_text = "Forward" if direction else "Reverse"
dir_color = "#00d992" if direction else "#ff6b35"
speed_pct = int(speed * 100)
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 Motor Controller</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d0d0d;
color: #f0ece8;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}}
.card {{
background: #1a1a1a;
border: 1px solid #2d2d2d;
border-radius: 16px;
padding: 40px;
text-align: center;
max-width: 400px;
width: 90%;
}}
h1 {{
font-size: 1.4rem;
margin-bottom: 0.5rem;
font-weight: 600;
}}
.speed-display {{
font-size: 3rem;
font-weight: 700;
margin: 20px 0 10px;
color: #00d992;
}}
.speed-label {{
font-size: 0.9rem;
color: #888;
margin-bottom: 20px;
}}
input[type="range"] {{
width: 100%;
margin: 10px 0;
cursor: pointer;
}}
.direction-btn {{
background: {dir_color};
color: #0d0d0d;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-top: 10px;
transition: opacity 0.2s;
}}
.direction-btn:hover {{
opacity: 0.85;
}}
.footer {{
margin-top: 24px;
font-size: 0.75rem;
color: #666;
}}
</style>
</head>
<body>
<div class="card">
<h1>ESP32 Motor Controller</h1>
<div class="speed-display">{speed_pct}%</div>
<div class="speed-label">Motor Speed</div>
<form method="get" action="/speed">
<input type="range" name="s" min="0" max="100" value="{speed_pct}" />
<button type="submit" class="direction-btn" style="background:#00d992">Set Speed</button>
</form>
<a href="/direction" class="direction-btn">Switch to {dir_text}</a>
<div class="footer">Connected to CryptoTavern Robotics</div>
</div>
</body>
</html>
"""
return html
--- Motor state ---
current_speed = 0.0
current_direction = True # True = forward
--- Web Server ---
addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(1)
print('Web server running. Visit http://' + wlan.ifconfig()[0])
while True:
conn, client_addr = s.accept()
print('Client connected from:', client_addr)
request = conn.recv(1024).decode()
if '/speed' in request:
# Parse speed from query string: /speed?s=75
try:
_, query = request.split('?', 1)
params = dict(p.split('=') for p in query.split('&') if '=' in p)
speed_val = int(params.get('s', '0'))
current_speed = speed_val / 100.0
set_speed(current_speed)
except:
pass
if '/direction' in request:
current_direction = not current_direction
set_direction(current_direction)
response = webpage(current_speed, current_direction)
conn.send('HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n')
conn.send(response)
conn.close()
'
Run this. Open the serial terminal. Connect to WiFi. You will see the IP address. Open that IP address in your browser.
Drag the slider. The motor speed changes.
Tap "Switch to Reverse." The motor changes direction.
You are controlling a physical machine from a webpage. The webpage from Lesson 04 controlled one output. This one controls the same kind of output -- just a more powerful one.
How the PWM Works -- Line by Line
Let me walk through the motor-specific parts:
'
from machine import Pin, PWM
'
PWM is a separate class from Pin. You create a PWM object attached to a pin.
'
motor_pwm = PWM(in_pwm)
motor_pwm.freq(500)
motor_pwm.duty_u16(0)
'
This creates a PWM object on GPIO6 (EN1 on the L298N). The frequency is 500Hz. The duty starts at 0 -- motor is stopped.
'
motor_pwm.duty_u16(65535) # full speed
motor_pwm.duty_u16(32768) # half speed (65535 / 2)
motor_pwm.duty_u16(0) # stopped
'
The duty is set in 16-bit resolution -- 0 to 65535. That gives you fine-grained speed control.
'
in1.value(1)
in2.value(0) # forward
'
IN1 high and IN2 low makes the motor spin one way. IN1 low and IN2 high reverses it. Both low or both high locks the motor (braking).
The Combined Circuit -- LED and Motor
If you want to keep the LED circuit from Lesson 04 and add the motor, here is the combined wiring:
+------------------------------------------------------------+
| |
| LED Circuit (Lesson 04) Motor Circuit |
| [GPIO2]---[330 ohm]---[LED]---[GND] [GPIO4]---IN1 |
| [GPIO5]---IN2 |
| [GPIO6]---EN1/PWM |
| [GND]------GND |
| |
| Same ESP32. Both circuits running simultaneously. |
| |
+------------------------------------------------------------+
Your Turn Challenge -- Speed Button
Right now the webpage controls motor speed with a slider. The challenge is simpler than it looks.
Modify the page so the button does this:
- When pressed (held down on the webpage): motor spins at full speed
- When released: motor slows to 30% speed (not stopped -- just slower)
This is the same toggle logic from Lesson 04, but with speed instead of on/off. The motor is either fast or slow depending on whether the button is pressed. The request goes to /fast when the button is pressed, and /slow when released.
Hint: use two links instead of a form -- one for /fast (100% speed) and one for /slow (30% speed). You can use a HTML button with an onmousedown/onmouseup JavaScript handler to send the requests automatically.
When it works, hold the button on your phone and watch the motor rev up. Let go and it settles to a slower spin. That is speed control via a webpage.
Key Concepts Learned
- DC motor: Electricity through a wire wound around a shaft creates a magnetic field that pushes against a permanent magnet, causing rotation. More voltage = faster spin.
- GPIO current limit: ESP32 GPIO pins max out at ~40mA. Motors need 100mA-300mA. You need a driver board in between.
- L298N H-bridge: Takes low-current control signals and switches high-current motor power. Lets you control direction (IN1/IN2) and speed (PWM on EN1).
- PWM (Pulse Width Modulation): Rapidly toggling a pin on and off. The motor averages the pulses -- 50% duty cycle = 50% speed.
- Duty cycle: The percentage of time a PWM signal is ON. 0% = stopped. 100% = full speed.
- Shared ground: GND must be common between ESP32, L298N, and battery. Without it, signals have no reference and nothing works.
- External power: The L298N draws motor power from the battery pack, not from the ESP32. Separate power domains, shared ground reference.
Troubleshooting
The motor does not spin when I change the speed.
Check: (1) Is the battery fresh? A weak battery will show voltage but cannot supply current. (2) Is the ground shared between ESP32 and L298N? (3) Are IN1 and IN2 wired correctly -- try swapping them if the motor only hums.
The motor spins at full speed even when the slider is at 0.
The L298N EN1 pin needs PWM to reduce speed. If you wired EN1 directly to a normal GPIO pin (not PWM), it will treat it as either full on or full off. Make sure you are using PWM on the right pin.
The ESP32 resets when the motor starts.
This is the classic symptom of insufficient shared ground or a battery that cannot supply enough current. The motor drawing power causes the ESP32's supply voltage to dip. Use a fresh battery and make sure the ground connection between ESP32 and L298N is solid.
The motor runs hot.
Small motors getting warm is normal. If it is too hot to touch, reduce the voltage (use fewer batteries) or add a heatsink to the L298N. Do not run a 6V motor on 9V without a speed controller to limit the voltage.
What's Next
You just added motion to your project. You have a web-controllable motor -- the core of every robot drive system.
In Lesson 06, you will add sensors. Specifically, you will wire an IR line sensor (TCRT5000) and write code that makes your robot follow a line on the ground. That is when it stops being a motor and starts being an autonomous agent.
The PWM speed control you learned here is the same technique used for servo motors, LED dimming, and tone generation. It is one of the most broadly applicable skills in embedded systems.
You just gave your robot legs. That changes everything.
CryptoTavern Robotics from Scratch is an evolving course. Each lesson improves over time as the community learns. If something was unclear, if you got stuck, or if something just worked and you want to say so -- that is what the course is for.
Next lesson: Lesson 06 -- Following Lines: IR Sensors and Closed-Loop Control