Skip to content

Project: Servo Controller

Servo motors are what make robot arms, camera mounts and RC car steering move. In this project you'll steer one yourself, in two different ways: with a dial for fast, natural control, and with two buttons for slow, precise control.

Controlling a servo with a potentiometer

Photo from the first edition. Follow the wiring table for pin numbers.

Objective

  • Version 1: the servo arm follows the potentiometer. Turn the dial and the arm turns with it.
  • Version 2: Button A turns the servo one way and Button B the other. Tap for a 1° nudge, or hold the button down to keep it moving.

You'll need:

  • 1 × servo motor (SG90 or similar)
  • 1 × potentiometer (version 1)
  • 2 × push buttons (version 2)
  • Jumper wires

Before you start

This project builds on Servo Motors, Potentiometers and Buttons. Version 2 needs button.py saved on your ESP32 (see Saving Programs to the Board).

Wiring

You can wire up everything at once and switch between the two versions just by running different code.

Part Wire / leg Connects to
Servo brown or black (ground) GND
red (power) VIN (5 V)
orange or yellow (signal) GPIO 13
Potentiometer 1 left leg 3V3
middle leg GPIO 34
right leg GND
Button A one leg GPIO 14, other leg to GND
Button B one leg GPIO 25, other leg to GND

Power the servo from VIN, not 3V3

Servos want about 5 V and can draw a lot of current when they move. VIN passes 5 V straight through from USB. If your ESP32 resets whenever the servo moves, the USB port can't keep up. Try a different port or a powered USB hub.

Plan it out

  1. Set up each component: the servo is a PWM output at 50 Hz, the potentiometer is an ADC input, and the buttons use Button from button.py.
  2. Break the job into functions.
    • set_angle(angle) turns an angle (0–180°) into the right pulse width for the servo. Both versions use it.
    • Version 1: read_pot_angle() turns the dial position into an angle.
    • Version 2: move(step) changes the angle by ±1° without going past 0 or 180. check_button() decides whether a button was tapped or is being held.
  3. Main loop: keep reading the input and move the servo to match.

Code

servo_pot_control.py
# Servo Controller (potentiometer version):
# the servo arm follows the potentiometer dial.
import time
from machine import Pin, PWM, ADC

SERVO_PIN = 13
POT_PIN = 34      # Potentiometer 1

# Pulse widths for 0 and 180 degrees. Adjust these if your servo
# doesn't reach the full range or buzzes at the ends.
SERVO_MIN_NS = 500_000     # 0.5 ms
SERVO_MAX_NS = 2_500_000   # 2.5 ms

servo = PWM(Pin(SERVO_PIN), freq=50, duty_u16=0)
pot = ADC(Pin(POT_PIN), atten=ADC.ATTN_11DB)


def set_angle(angle):
    pulse = SERVO_MIN_NS + (SERVO_MAX_NS - SERVO_MIN_NS) * angle // 180
    servo.duty_ns(pulse)


def read_pot_angle():
    # Average a few readings: the ADC is a little noisy, and without
    # this the servo would twitch even when you aren't touching the dial.
    total = 0
    for _ in range(8):
        total += pot.read_u16()
    average = total // 8
    return average * 180 // 65535


current_angle = -1  # -1 means "not set yet"

while True:
    angle = read_pot_angle()
    # only move the servo when the dial has really changed
    if abs(angle - current_angle) >= 1:
        set_angle(angle)
        current_angle = angle
    time.sleep_ms(20)
servo_button_control.py
# Servo Controller (button version):
# Button A turns the servo one way, Button B turns it the other way.
# Tap for 1 degree, or hold the button down to keep moving.
import time
from machine import Pin, PWM
from button import Button

SERVO_PIN = 13
BUTTON_A_PIN = 14
BUTTON_B_PIN = 25

SERVO_MIN_NS = 500_000     # pulse width for 0 degrees
SERVO_MAX_NS = 2_500_000   # pulse width for 180 degrees
HOLD_DELAY_MS = 400        # how long to hold before it starts repeating
REPEAT_MS = 50             # while held, move 1 degree this often

servo = PWM(Pin(SERVO_PIN), freq=50, duty_u16=0)
button_a = Button(BUTTON_A_PIN)
button_b = Button(BUTTON_B_PIN)

angle = 90                 # start in the middle
held_since = 0             # when the current hold started
last_step = 0              # when we last moved while holding


def set_angle(new_angle):
    pulse = SERVO_MIN_NS + (SERVO_MAX_NS - SERVO_MIN_NS) * new_angle // 180
    servo.duty_ns(pulse)


def move(step):
    global angle
    # clamp keeps the angle between 0 and 180
    new_angle = max(0, min(180, angle + step))
    if new_angle != angle:
        angle = new_angle
        set_angle(angle)
        print("Angle:", angle)


def check_button(button, step):
    global held_since, last_step
    now = time.ticks_ms()
    if button.was_pressed():
        move(step)                 # a tap moves 1 degree
        held_since = now
        last_step = now
    elif button.is_down():
        held_for = time.ticks_diff(now, held_since)
        since_step = time.ticks_diff(now, last_step)
        if held_for > HOLD_DELAY_MS and since_step >= REPEAT_MS:
            move(step)             # still held: keep moving
            last_step = now


set_angle(angle)

while True:
    check_button(button_a, -1)
    check_button(button_b, +1)
    time.sleep_ms(5)

Save whichever version you want to use as main.py on the ESP32 to have it start automatically.

How it works

Angles and pulses

A servo decides where to point by measuring how long each pulse on its signal wire lasts. There are 50 pulses a second, and:

Pulse width Angle
0.5 ms (500,000 ns) 0°
1.5 ms (1,500,000 ns) 90°
2.5 ms (2,500,000 ns) 180°

set_angle() maps 0–180 onto SERVO_MIN_NS–SERVO_MAX_NS and hands the result to duty_ns(). Every servo is a little different: if yours doesn't quite reach the ends, or buzzes when it gets there, nudge those two constants (try 600,000 and 2,400,000).

Version 1: smoothing the dial

The ESP32's analog input is a bit noisy. Even when you aren't touching the dial, the reading jumps around slightly, which would make the servo twitch. The code fixes this in two ways:

  • read_pot_angle() takes 8 readings and averages them.
  • The main loop only moves the servo when the angle has changed by at least one whole degree.

Version 2: tap vs. hold

Button.was_pressed() is True just once per press. That's the 1° tap. Button.is_down() is True the whole time the button is held down. Once it's been held for HOLD_DELAY_MS (0.4 s), the code moves another degree every REPEAT_MS (50 ms). This is exactly how the arrow keys on your keyboard repeat.

max(0, min(180, angle + step)) is a handy pattern called clamping: whatever angle + step works out to, the result is kept between 0 and 180.

Result

Photos from the first edition. Follow the wiring table for pin numbers.

Make it your own

  • Speed control: in version 2, make the servo speed up the longer you hold the button.
  • Both at once: combine the versions, using the dial for rough positioning and the buttons for fine-tuning.
  • Pan and tilt: add a second servo on GPIO 27 controlled by Potentiometer 2 (GPIO 35), tape a small cardboard "camera" on top, and you've built a pan-and-tilt mount.