Overview
You can create a direct servo motor controller with rotary encoder input, saved positions, and animation features. It's all coded in CircuitPython and runs on minimal hardware -- an ESP32-S2 Reverse TFT Feather, two rotary encoders, and batteries all mounted on a perma proto board. Easily swap in different servos and use the display to get all the relevant motor angle details. Become the boss of your servos!
or:
2 each:
2 each:
Page last edited July 03, 2024
Text editor powered by tinymce.
Build the Servo Boss
Perma Proto
The Servo Boss is built on a perma proto board for long-term stability. Alternately, you can build it on a breadboard.
Power Jack
Solder the power jack as shown, then wire the center pin to the power rail and sleeve pin to ground.
Encoder Prep
Bend the outer legs as shown -- these are used for mechanically securing the encoders in PCBs, but there aren't holes for this in the perma proto (or breadboard).
Fancy Servo Headers
If you want extra fancy header pins for your servos you're in the right spot! Long header pins plus color coded plastic nubs do the trick so you can easily differentiate between power, ground, and signal.
Press the long header pins' plastic spacers down with pliers (and the pins on a hard surface), then extricate colored spacers from some donor pins.
Slide the colored spacers over the long header pins as shown, black-red-yellow ordered.
Feather Headers
Next solder in socket headers for the Feather. Since we're using the Reverse TFT Feather it is "face down" compared to typical Feather mounting.
Servo Header Power
Solder two red wires to the center pins of the servo headers so they connect to the power rail that'll be fed by the external 5V DC power supply or battery pack.
Rotary Encoder Wiring
Solder the two encoder pulse pins to their associated Feather input pins, A1/A2 and A4/A5.
Enable Switch
Solder the slide switch in place as shown, running the center pin to ground and the left pin to the En pin on the Feather -- this will turn the Feather on and off.
Mounting
You can go the extra step to mount your Servo Boss perma proto to an aluminum swirly grid using M2.5 screws and standoffs.
Add a AA battery pack secured with double stick foam tape or a zip tie (as seen here) and a pan-tilt servo set. This will give you a portable, all-in-one servo platform!
Page last edited July 03, 2024
Text editor powered by tinymce.
CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.
For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called FTHRS2BOOT.
Drag the adafruit_circuitpython_etc.uf2 file to FTHRS2BOOT.
Page last edited July 03, 2024
Text editor powered by tinymce.
Code the Servo Boss
Text Editor
Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.
Alternatively, you can use any text editor that saves simple text files.
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.
Drag the contents of the uncompressed bundle directory onto your board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
# SPDX-FileCopyrightText: 2024 johnpark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''
Servo Commander
- Feather Reverse TFT ESP32-S3 + two servos + two push encoders
- test servo ranges with encoder rotation
- store saved positions with enc button + D0, D1, D2 buttons
- recall saved positions with D0, D1, D2
- playback animation by pressing both push encoders
'''
import time
import board
import displayio
import terminalio
from adafruit_display_text import label
from adafruit_display_shapes.rect import Rect
import rotaryio
import pwmio
from adafruit_motor import servo
import keypad
# Define custom servo pulse range variables
s_cfgs = [
{'min_pulse': 600, 'max_pulse': 2400, 'min_ang': 10, 'max_ang': 170},
{'min_pulse': 600, 'max_pulse': 2300, 'min_ang': 10, 'max_ang': 170}
]
# Setup the PWM output for the servos
pwm_pins = [board.D10, board.D11]
servo_motors = []
for i, config in enumerate(s_cfgs):
pwm = pwmio.PWMOut(pwm_pins[i], duty_cycle=2**15, frequency=50)
servo_motor = servo.Servo(pwm, min_pulse=config['min_pulse'], max_pulse=config['max_pulse'])
servo_motors.append(servo_motor)
servo_motor.angle = 90
s_saves = [
[s_cfgs[0]['min_ang'], 90, s_cfgs[0]['max_ang']],
[s_cfgs[1]['min_ang'], 90, s_cfgs[1]['max_ang']]
]
# Setup the rotary encs
encs = [
rotaryio.IncrementalEncoder(board.A2, board.A1),
rotaryio.IncrementalEncoder(board.A5, board.A4)
]
for enc in encs:
enc.position = 90
last_positions = [enc.position for enc in encs]
# Setup the buttons
enc_buttons = keypad.Keys((board.D13, board.D12), value_when_pressed=False, pull=True)
tft_d0_button = keypad.Keys((board.D0,), value_when_pressed=False, pull=True)
tft_buttons = keypad.Keys((board.D1, board.D2), value_when_pressed=True, pull=True)
def set_servo_angle(s_servo_motor, s_angle, s_enc, min_ang, max_ang):
s_angle = min(max(s_angle, min_ang), max_ang)
s_servo_motor.angle = s_angle
s_enc.position = s_angle
def playback(mode, speed, steps):
for k in range(3): # Loop through each save position (assuming 3 saves per motor)
for m in range(len(servo_motors)):
p_servo_motor = servo_motors[m]
p_enc = encs[m]
save = s_saves[m][k] # Get the k-th save position for the m-th motor
if mode:
direction = 1 if save > enc.position else -1
for p_angle in range(enc.position, save, direction * steps):
p_servo_motor.angle = p_angle
time.sleep(speed)
p_enc.position = save
else:
servo_motor.angle = save
time.sleep(0.75)
# Setup the display
display = board.DISPLAY
group = displayio.Group()
background_rect = Rect(0, 10, display.width, display.height - 10, fill=0x000010)
group.append(background_rect)
mid_bar = Rect(116, 0, 3, display.height, fill=0x00000)
group.append(mid_bar)
top_bar = Rect(0, 0, display.width, 20, fill=0x000000)
group.append(top_bar)
FONT = terminalio.FONT
TXTCOL = 0xFFFF00
# Create labels
labels = []
for i, config in enumerate(s_cfgs):
lbl = label.Label(FONT, text=f"Pulse: {config['min_pulse']}-{config['max_pulse']}",color=TXTCOL,
scale=1, anchor_point=(0, 0), anchored_position=(5 + 125 * i, 5))
labels.append(lbl)
group.append(lbl)
for i in range(2):
lbl = label.Label(FONT, text="Angle:-", color=TXTCOL, scale=2, anchor_point=(0, 0),
anchored_position=(4 + 126 * i, 24))
labels.append(lbl)
group.append(lbl)
for i in range(2):
for j in range(3):
lbl = label.Label(FONT, text=f"D{j}:{s_saves[i][j]}", color=TXTCOL, scale=2,
anchor_point=(0, 0), anchored_position=(4 + i * 126, 48 + 24 * j))
labels.append(lbl)
group.append(lbl)
display.root_group = group
modifier1 = False
modifier2 = False
print("[]-Servo Commander READY-[]")
while True:
enc_button_event = enc_buttons.events.get()
if enc_button_event:
if enc_button_event.pressed:
if enc_button_event.key_number == 0:
modifier1 = True
elif enc_button_event.key_number == 1:
modifier2 = True
if enc_button_event.released:
if enc_button_event.key_number == 0:
modifier1 = False
elif enc_button_event.key_number == 1:
modifier2 = False
if modifier1 and modifier2:
print("Playback")
playback(True, 0.006, 2)
tft_d0_button_event = tft_d0_button.events.get()
if tft_d0_button_event and tft_d0_button_event.pressed:
if modifier1:
s_saves[0][0] = min(max(encs[0].position, s_cfgs[0]['min_ang']), s_cfgs[0]['max_ang'])
print("D0 save motor1:", s_saves[0][0])
labels[4].text = f"D0:{s_saves[0][0]}"
elif modifier2:
s_saves[1][0] = min(max(encs[1].position, s_cfgs[1]['min_ang']), s_cfgs[1]['max_ang'])
print("D0 save motor2:", s_saves[1][0])
labels[7].text = f"D0:{s_saves[1][0]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][0]
config = s_cfgs[i]
print(f"D0 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
tft_buttons_event = tft_buttons.events.get()
if tft_buttons_event and tft_buttons_event.pressed:
if tft_buttons_event.key_number == 0:
if modifier1:
s_saves[0][1] = min(max(encs[0].position,s_cfgs[0]['min_ang']),s_cfgs[0]['max_ang'])
print("D1 save motor1:", s_saves[0][1])
labels[5].text = f"D1:{s_saves[0][1]}"
elif modifier2:
s_saves[1][1] = min(max(encs[1].position,s_cfgs[1]['min_ang']),s_cfgs[1]['max_ang'])
print("D1 save motor2:", s_saves[1][1])
labels[8].text = f"D1:{s_saves[1][1]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][1]
config = s_cfgs[i]
print(f"D1 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
elif tft_buttons_event.key_number == 1:
if modifier1:
s_saves[0][2] = min(max(encs[0].position,s_cfgs[0]['min_ang']),s_cfgs[0]['max_ang'])
print("D2 save motor1:", s_saves[0][2])
labels[6].text = f"D2:{s_saves[0][2]}"
elif modifier2:
s_saves[1][2] = min(max(encs[1].position,s_cfgs[1]['min_ang']),s_cfgs[1]['max_ang'])
print("D2 save motor2:", s_saves[1][2])
labels[9].text = f"D2:{s_saves[1][2]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][2]
config = s_cfgs[i]
print(f"D2 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
for i in range(len(servo_motors)):
current_position = encs[i].position
if current_position != last_positions[i]:
config = s_cfgs[i]
angle = min(max(current_position, config['min_ang']), config['max_ang'])
servo_motor = servo_motors[i]
enc = encs[i]
set_servo_angle(servo_motor, angle, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{angle}"
last_positions[i] = current_position
How It Works
The Servo Boss uses a Feather Reverse TFT ESP32-S2 to control two servo motors using two rotary encoders and several buttons. You can use it to test servo ranges with encoder rotation, store positions using button presses, recall these positions, and play back stored animations.
Setup
Libraries
First, you'll import the required libraries, including displayio related libraries, rotaryio for encoder reading, and pwmio and adafruit_motor for driving servos.
import time import board import displayio import terminalio from adafruit_display_text import label from adafruit_display_shapes.rect import Rect import rotaryio import pwmio from adafruit_motor import servo import keypad
Servo Configuration
Begin by setting up the servo motors. Two servos are initialized with custom pulse ranges, which define their minimum and maximum pulse widths. The servos are connected to pins D10 and D11:
s_cfgs = [
{'min_pulse': 600, 'max_pulse': 2400, 'min_ang': 10, 'max_ang': 170},
{'min_pulse': 600, 'max_pulse': 2300, 'min_ang': 10, 'max_ang': 170}
]
Each servo is configured using the pwmio and adafruit_motor libraries, and their initial angles are set to 90 degrees:
pwm_pins = [board.D10, board.D11]
servo_motors = []
for i, config in enumerate(s_cfgs):
pwm = pwmio.PWMOut(pwm_pins[i], duty_cycle=2**15, frequency=50)
servo_motor = servo.Servo(pwm, min_pulse=config['min_pulse'], max_pulse=config['max_pulse'])
servo_motors.append(servo_motor)
servo_motor.angle = 90
Rotary Encoders
Two rotary encoders are set up to control the angles of the servos. The encoders are connected to pins D10 and D11 and then initialized with a starting position of 90 degrees:
encs = [
rotaryio.IncrementalEncoder(board.A2, board.A1),
rotaryio.IncrementalEncoder(board.A5, board.A4)
]
for enc in encs:
enc.position = 90
Button Configuration
Several buttons are configured to save and recall positions. Two encoder buttons and the three buttons (D0, D1, D2) on the TFT Feather are set up using the keypad library:
enc_buttons = keypad.Keys((board.D13, board.D12), value_when_pressed=False, pull=True) tft_d0_button = keypad.Keys((board.D0,), value_when_pressed=False, pull=True) tft_buttons = keypad.Keys((board.D1, board.D2), value_when_pressed=True, pull=True)
Display Setup
The display is initialized to show the servo pulse ranges, current angles, and saved positions:
display = board.DISPLAY group = displayio.Group() background_rect = Rect(0, 10, display.width, display.height - 10, fill=0x000010) group.append(background_rect) # Additional display elements are added here display.root_group = group
Main Loop
The main loop checks for button presses and encoder changes in order to control the servos and update the display:
Button Presses
- Single Encoder Buttons: When either encoder button is pressed along with one of the TFT buttons, the current position for the relative servo is stored in that save slot.
- Both Encoder Buttons: When both encoder buttons are pressed, the playback function is triggered to play back stored animations.
- D0, D1, D2 Buttons: When any of these are pressed on their own, both servo motors go to their saved positions for that save slot. When pressed at the same time as an encoder button saves the current position.
while True:
enc_button_event = enc_buttons.events.get()
if enc_button_event:
if enc_button_event.pressed:
if enc_button_event.key_number == 0:
modifier1 = True
elif enc_button_event.key_number == 1:
modifier2 = True
if enc_button_event.released:
if enc_button_event.key_number == 0:
modifier1 = False
elif enc_button_event.key_number == 1:
modifier2 = False
if modifier1 and modifier2:
print("Playback")
playback(True, 0.006, 2)
tft_d0_button_event = tft_d0_button.events.get()
if tft_d0_button_event and tft_d0_button_event.pressed:
if modifier1:
s_saves[0][0] = min(max(encs[0].position, s_cfgs[0]['min_ang']), s_cfgs[0]['max_ang'])
print("D0 save motor1:", s_saves[0][0])
labels[4].text = f"D0:{s_saves[0][0]}"
elif modifier2:
s_saves[1][0] = min(max(encs[1].position, s_cfgs[1]['min_ang']), s_cfgs[1]['max_ang'])
print("D0 save motor2:", s_saves[1][0])
labels[7].text = f"D0:{s_saves[1][0]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][0]
config = s_cfgs[i]
print(f"D0 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
tft_buttons_event = tft_buttons.events.get()
if tft_buttons_event and tft_buttons_event.pressed:
if tft_buttons_event.key_number == 0:
if modifier1:
s_saves[0][1] = min(max(encs[0].position,s_cfgs[0]['min_ang']),s_cfgs[0]['max_ang'])
print("D1 save motor1:", s_saves[0][1])
labels[5].text = f"D1:{s_saves[0][1]}"
elif modifier2:
s_saves[1][1] = min(max(encs[1].position,s_cfgs[1]['min_ang']),s_cfgs[1]['max_ang'])
print("D1 save motor2:", s_saves[1][1])
labels[8].text = f"D1:{s_saves[1][1]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][1]
config = s_cfgs[i]
print(f"D1 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
elif tft_buttons_event.key_number == 1:
if modifier1:
s_saves[0][2] = min(max(encs[0].position,s_cfgs[0]['min_ang']),s_cfgs[0]['max_ang'])
print("D2 save motor1:", s_saves[0][2])
labels[6].text = f"D2:{s_saves[0][2]}"
elif modifier2:
s_saves[1][2] = min(max(encs[1].position,s_cfgs[1]['min_ang']),s_cfgs[1]['max_ang'])
print("D2 save motor2:", s_saves[1][2])
labels[9].text = f"D2:{s_saves[1][2]}"
else:
for i in range(len(servo_motors)):
servo_motor = servo_motors[i]
enc = encs[i]
s_save = s_saves[i][2]
config = s_cfgs[i]
print(f"D2 recalled motor{i+1}:", s_save)
set_servo_angle(servo_motor, s_save, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{s_save}"
Direct Servo Movements
When you turn either rotary encoder, the corresponding servo angle is updated, while clamping the encoder values in the defined range:
for i in range(len(servo_motors)):
current_position = encs[i].position
if current_position != last_positions[i]:
config = s_cfgs[i]
angle = min(max(current_position, config['min_ang']), config['max_ang'])
servo_motor = servo_motors[i]
enc = encs[i]
set_servo_angle(servo_motor, angle, enc, config['min_ang'], config['max_ang'])
labels[2 + i].text = f"Angle:{angle}"
last_positions[i] = current_position
Playback Function
The playback function animates the servos through saved positions, either smoothly or instantly depending on the mode:
def playback(mode, speed, steps):
for k in range(3):
for m in range(len(servo_motors)):
p_servo_motor = servo_motors[m]
p_enc = encs[m]
save = s_saves[m][k]
if mode:
direction = 1 if save > enc.position else -1
for p_angle in range(enc.position, save, direction * steps):
p_servo_motor.angle = p_angle
time.sleep(speed)
p_enc.position = save
else:
servo_motor.angle = save
time.sleep(0.75)
Page last edited July 03, 2024
Text editor powered by tinymce.
Use the Servo Boss
Display
The display info is split into two halves, left servo and right servo, each with identical information for their respective motors.
- Pulse: At the top, the display shows the Pulse lengths for the two servos as set in code.
- Angle: Next is the Angle for the servo as currently set either by the direct knob control or with saved postion recall from one of the buttons.
- Saved Positions: Three saved positions, labeled D0, D1, and D2 are shown. These are the minº, 90º, maxº at startup, but can be changed as shown below.
Direct Control
Turn the two encoder knobs to directly rotate the two servo motors. The motor will turn and the display will update the Angle value to match.
Position Recall
Press the D0, D1, or D2 buttons on the Feather to recall a pre-saved position. Both servos will go to their saved position for a given save slot.
Save Position
Here are the steps to save a new position:
- Rotate encoder knob to go to the desired position
- Press and hold the encoder push knob
- While keeping the encoder knob pressed, press any of the Feather D0, D1, or D2 buttons to save that angle position to that slot for the selected encoder/servo
- Release the Feather D0, D1, or D2 button as well as the encoder knob button
That's it, the position is saved. Now, when you press that D0, D1, or D2 button the servo will return to that saved position.
Playback
Press both encoder buttons at the same time to play back all of the positions in order in a continuous loop. This is great for testing out animatronic animations or robotic motion sequences. Release the two encoder buttons to stop.
Page last edited July 03, 2024
Text editor powered by tinymce.