Joystick pan-tilt turret
Steer two servos with a thumb joystick, like a camera turret. Add an MPU-6050 and a click of the stick switches to tilt steering: point your hand and the turret follows.
Wiring
| From | To board | Note |
|---|---|---|
| Joystick +5V or VCC | 3V3 | 3V3, not 5 V: at 5 V the stick could send more than 3.3 V into the ESP32 |
| Joystick GND | GND | |
| Joystick VRx | GPIO 34 | An ADC1 pin |
| Joystick VRy | GPIO 35 | An ADC1 pin |
| Joystick SW | GPIO 27 | The internal pull-up does the rest |
| Pan servo signal | GPIO 18 | |
| Tilt servo signal | GPIO 19 | |
| Both servos red | 5V | Fine from USB for two micro servos that move gently; use a separate 5 V supply if the board resets |
| Both servos brown | GND | |
| Optional MPU-6050 SDA and SCL | GPIO 21 and 22 | VCC to 3V3, GND to GND. Found automatically at start-up |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32 Dev Module
// Joystick pan-tilt turret: push the stick and two servos follow. Click the stick
// to switch to tilt mode, where an MPU-6050 in your hand steers instead (if one
// is wired; without it the button does nothing).
#include <Wire.h>
#include <ESP32Servo.h>
const int JOY_X = 34; // ADC1 pins
const int JOY_Y = 35;
const int JOY_BTN = 27; // the stick's push button (SW)
const int PAN_PIN = 18;
const int TILT_PIN = 19;
const int SDA_PIN = 21; // optional MPU-6050 for tilt mode
const int SCL_PIN = 22;
const float DEADZONE = 0.08f; // ignore the stick this close to centre (fraction of full throw)
const float MAX_SPEED = 120.0f; // degrees per second at full throw
const int MIN_ANGLE = 10, MAX_ANGLE = 170;
const float TILT_GAIN = 1.5f; // tilt mode: servo degrees per degree of hand tilt
const uint8_t MPU = 0x68;
Servo pan, tilt;
float panAngle = 90, tiltAngle = 90;
int centerX = 2048, centerY = 2048;
bool hasMpu = false, tiltMode = false;
unsigned long lastMs = 0, lastPrint = 0;
int averageRead(int pin) {
long sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(pin);
return sum / 16;
}
// Stick position from -1 to 1, with the dead zone removed and the rest rescaled.
float axis(int pin, int center) {
const int raw = averageRead(pin);
const float span = raw >= center ? 4095 - center : center;
float v = (raw - center) / (span > 0 ? span : 1);
if (fabsf(v) < DEADZONE) return 0;
v = (fabsf(v) - DEADZONE) / (1 - DEADZONE) * (v > 0 ? 1 : -1);
return constrain(v, -1.0f, 1.0f);
}
bool readTilt(float& pitch, float& roll) {
Wire.beginTransmission(MPU);
Wire.write(0x3B);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)6) != 6) return false;
const float ax = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float ay = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float az = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
pitch = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
roll = degrees(atan2f(ay, az));
return true;
}
void setup() {
Serial.begin(115200);
pinMode(JOY_BTN, INPUT_PULLUP);
analogSetPinAttenuation(JOY_X, ADC_11db);
analogSetPinAttenuation(JOY_Y, ADC_11db);
pan.setPeriodHertz(50);
tilt.setPeriodHertz(50);
pan.attach(PAN_PIN, 500, 2400);
tilt.attach(TILT_PIN, 500, 2400);
pan.write(90);
tilt.write(90);
// The stick rests at its centre now: remember where that is.
delay(300);
centerX = averageRead(JOY_X);
centerY = averageRead(JOY_Y);
Wire.begin(SDA_PIN, SCL_PIN);
Wire.beginTransmission(MPU);
hasMpu = Wire.endTransmission() == 0;
if (hasMpu) {
Wire.beginTransmission(MPU);
Wire.write(0x6B); // PWR_MGMT_1: wake up
Wire.write(0);
Wire.endTransmission();
}
Serial.printf("Turret ready. MPU-6050 %s.\n", hasMpu ? "found: click the stick for tilt mode" : "not found: stick only");
lastMs = millis();
}
void loop() {
const unsigned long now = millis();
const float dt = (now - lastMs) / 1000.0f;
lastMs = now;
// Click to switch modes, only when an MPU-6050 is there to steer with.
if (hasMpu && digitalRead(JOY_BTN) == LOW) {
tiltMode = !tiltMode;
Serial.println(tiltMode ? "Tilt mode: steer by tilting the MPU-6050" : "Stick mode");
while (digitalRead(JOY_BTN) == LOW) delay(10);
delay(30); // debounce the release
}
float pitch, roll;
if (tiltMode && readTilt(pitch, roll)) {
panAngle = 90 + roll * TILT_GAIN;
tiltAngle = 90 + pitch * TILT_GAIN;
} else {
// Stick mode: the further you push, the faster it turns, and it stays put when you let go.
panAngle += axis(JOY_X, centerX) * MAX_SPEED * dt;
tiltAngle += axis(JOY_Y, centerY) * MAX_SPEED * dt;
}
panAngle = constrain(panAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
tiltAngle = constrain(tiltAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
pan.write((int)roundf(panAngle));
tilt.write((int)roundf(tiltAngle));
if (now - lastPrint >= 150) {
lastPrint = now;
Serial.printf("pan = %d, tilt = %d\n", (int)roundf(panAngle), (int)roundf(tiltAngle));
}
delay(15);
}Wiring
| From | To board | Note |
|---|---|---|
| Joystick +5V or VCC | 3V3 | 3V3, not 5 V: at 5 V the stick could send more than 3.3 V into the ESP32 |
| Joystick GND | GND | |
| Joystick VRx | GPIO 1 | An ADC1 pin |
| Joystick VRy | GPIO 2 | An ADC1 pin |
| Joystick SW | GPIO 4 | The internal pull-up does the rest |
| Pan servo signal | GPIO 5 | |
| Tilt servo signal | GPIO 6 | |
| Both servos red | 5V | Fine from USB for two micro servos that move gently; use a separate 5 V supply if the board resets |
| Both servos brown | GND | |
| Optional MPU-6050 SDA and SCL | GPIO 8 and 9 | VCC to 3V3, GND to GND. Found automatically at start-up |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32S3 Dev Module
// Joystick pan-tilt turret: push the stick and two servos follow. Click the stick
// to switch to tilt mode, where an MPU-6050 in your hand steers instead (if one
// is wired; without it the button does nothing).
#include <Wire.h>
#include <ESP32Servo.h>
const int JOY_X = 1; // ADC1 pins
const int JOY_Y = 2;
const int JOY_BTN = 4; // the stick's push button (SW)
const int PAN_PIN = 5;
const int TILT_PIN = 6;
const int SDA_PIN = 8; // optional MPU-6050 for tilt mode
const int SCL_PIN = 9;
const float DEADZONE = 0.08f; // ignore the stick this close to centre (fraction of full throw)
const float MAX_SPEED = 120.0f; // degrees per second at full throw
const int MIN_ANGLE = 10, MAX_ANGLE = 170;
const float TILT_GAIN = 1.5f; // tilt mode: servo degrees per degree of hand tilt
const uint8_t MPU = 0x68;
Servo pan, tilt;
float panAngle = 90, tiltAngle = 90;
int centerX = 2048, centerY = 2048;
bool hasMpu = false, tiltMode = false;
unsigned long lastMs = 0, lastPrint = 0;
int averageRead(int pin) {
long sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(pin);
return sum / 16;
}
// Stick position from -1 to 1, with the dead zone removed and the rest rescaled.
float axis(int pin, int center) {
const int raw = averageRead(pin);
const float span = raw >= center ? 4095 - center : center;
float v = (raw - center) / (span > 0 ? span : 1);
if (fabsf(v) < DEADZONE) return 0;
v = (fabsf(v) - DEADZONE) / (1 - DEADZONE) * (v > 0 ? 1 : -1);
return constrain(v, -1.0f, 1.0f);
}
bool readTilt(float& pitch, float& roll) {
Wire.beginTransmission(MPU);
Wire.write(0x3B);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)6) != 6) return false;
const float ax = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float ay = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float az = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
pitch = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
roll = degrees(atan2f(ay, az));
return true;
}
void setup() {
Serial.begin(115200);
pinMode(JOY_BTN, INPUT_PULLUP);
analogSetPinAttenuation(JOY_X, ADC_11db);
analogSetPinAttenuation(JOY_Y, ADC_11db);
pan.setPeriodHertz(50);
tilt.setPeriodHertz(50);
pan.attach(PAN_PIN, 500, 2400);
tilt.attach(TILT_PIN, 500, 2400);
pan.write(90);
tilt.write(90);
// The stick rests at its centre now: remember where that is.
delay(300);
centerX = averageRead(JOY_X);
centerY = averageRead(JOY_Y);
Wire.begin(SDA_PIN, SCL_PIN);
Wire.beginTransmission(MPU);
hasMpu = Wire.endTransmission() == 0;
if (hasMpu) {
Wire.beginTransmission(MPU);
Wire.write(0x6B); // PWR_MGMT_1: wake up
Wire.write(0);
Wire.endTransmission();
}
Serial.printf("Turret ready. MPU-6050 %s.\n", hasMpu ? "found: click the stick for tilt mode" : "not found: stick only");
lastMs = millis();
}
void loop() {
const unsigned long now = millis();
const float dt = (now - lastMs) / 1000.0f;
lastMs = now;
// Click to switch modes, only when an MPU-6050 is there to steer with.
if (hasMpu && digitalRead(JOY_BTN) == LOW) {
tiltMode = !tiltMode;
Serial.println(tiltMode ? "Tilt mode: steer by tilting the MPU-6050" : "Stick mode");
while (digitalRead(JOY_BTN) == LOW) delay(10);
delay(30); // debounce the release
}
float pitch, roll;
if (tiltMode && readTilt(pitch, roll)) {
panAngle = 90 + roll * TILT_GAIN;
tiltAngle = 90 + pitch * TILT_GAIN;
} else {
// Stick mode: the further you push, the faster it turns, and it stays put when you let go.
panAngle += axis(JOY_X, centerX) * MAX_SPEED * dt;
tiltAngle += axis(JOY_Y, centerY) * MAX_SPEED * dt;
}
panAngle = constrain(panAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
tiltAngle = constrain(tiltAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
pan.write((int)roundf(panAngle));
tilt.write((int)roundf(tiltAngle));
if (now - lastPrint >= 150) {
lastPrint = now;
Serial.printf("pan = %d, tilt = %d\n", (int)roundf(panAngle), (int)roundf(tiltAngle));
}
delay(15);
}Wiring
| From | To board | Note |
|---|---|---|
| Joystick +5V or VCC | 3V3 | 3V3, not 5 V: at 5 V the stick could send more than 3.3 V into the ESP32 |
| Joystick GND | GND | |
| Joystick VRx | GPIO 0 | An ADC1 pin |
| Joystick VRy | GPIO 1 | An ADC1 pin |
| Joystick SW | GPIO 10 | The internal pull-up does the rest |
| Pan servo signal | GPIO 4 | |
| Tilt servo signal | GPIO 5 | |
| Both servos red | 5V | Fine from USB for two micro servos that move gently; use a separate 5 V supply if the board resets |
| Both servos brown | GND | |
| Optional MPU-6050 SDA and SCL | GPIO 6 and 7 | VCC to 3V3, GND to GND. Found automatically at start-up |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32C3 Dev Module
// Joystick pan-tilt turret: push the stick and two servos follow. Click the stick
// to switch to tilt mode, where an MPU-6050 in your hand steers instead (if one
// is wired; without it the button does nothing).
#include <Wire.h>
#include <ESP32Servo.h>
const int JOY_X = 0; // ADC1 pins
const int JOY_Y = 1;
const int JOY_BTN = 10; // the stick's push button (SW)
const int PAN_PIN = 4;
const int TILT_PIN = 5;
const int SDA_PIN = 6; // optional MPU-6050 for tilt mode
const int SCL_PIN = 7;
const float DEADZONE = 0.08f; // ignore the stick this close to centre (fraction of full throw)
const float MAX_SPEED = 120.0f; // degrees per second at full throw
const int MIN_ANGLE = 10, MAX_ANGLE = 170;
const float TILT_GAIN = 1.5f; // tilt mode: servo degrees per degree of hand tilt
const uint8_t MPU = 0x68;
Servo pan, tilt;
float panAngle = 90, tiltAngle = 90;
int centerX = 2048, centerY = 2048;
bool hasMpu = false, tiltMode = false;
unsigned long lastMs = 0, lastPrint = 0;
int averageRead(int pin) {
long sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(pin);
return sum / 16;
}
// Stick position from -1 to 1, with the dead zone removed and the rest rescaled.
float axis(int pin, int center) {
const int raw = averageRead(pin);
const float span = raw >= center ? 4095 - center : center;
float v = (raw - center) / (span > 0 ? span : 1);
if (fabsf(v) < DEADZONE) return 0;
v = (fabsf(v) - DEADZONE) / (1 - DEADZONE) * (v > 0 ? 1 : -1);
return constrain(v, -1.0f, 1.0f);
}
bool readTilt(float& pitch, float& roll) {
Wire.beginTransmission(MPU);
Wire.write(0x3B);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)6) != 6) return false;
const float ax = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float ay = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
const float az = (int16_t)((Wire.read() << 8) | Wire.read()) / 16384.0f;
pitch = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
roll = degrees(atan2f(ay, az));
return true;
}
void setup() {
Serial.begin(115200);
pinMode(JOY_BTN, INPUT_PULLUP);
analogSetPinAttenuation(JOY_X, ADC_11db);
analogSetPinAttenuation(JOY_Y, ADC_11db);
pan.setPeriodHertz(50);
tilt.setPeriodHertz(50);
pan.attach(PAN_PIN, 500, 2400);
tilt.attach(TILT_PIN, 500, 2400);
pan.write(90);
tilt.write(90);
// The stick rests at its centre now: remember where that is.
delay(300);
centerX = averageRead(JOY_X);
centerY = averageRead(JOY_Y);
Wire.begin(SDA_PIN, SCL_PIN);
Wire.beginTransmission(MPU);
hasMpu = Wire.endTransmission() == 0;
if (hasMpu) {
Wire.beginTransmission(MPU);
Wire.write(0x6B); // PWR_MGMT_1: wake up
Wire.write(0);
Wire.endTransmission();
}
Serial.printf("Turret ready. MPU-6050 %s.\n", hasMpu ? "found: click the stick for tilt mode" : "not found: stick only");
lastMs = millis();
}
void loop() {
const unsigned long now = millis();
const float dt = (now - lastMs) / 1000.0f;
lastMs = now;
// Click to switch modes, only when an MPU-6050 is there to steer with.
if (hasMpu && digitalRead(JOY_BTN) == LOW) {
tiltMode = !tiltMode;
Serial.println(tiltMode ? "Tilt mode: steer by tilting the MPU-6050" : "Stick mode");
while (digitalRead(JOY_BTN) == LOW) delay(10);
delay(30); // debounce the release
}
float pitch, roll;
if (tiltMode && readTilt(pitch, roll)) {
panAngle = 90 + roll * TILT_GAIN;
tiltAngle = 90 + pitch * TILT_GAIN;
} else {
// Stick mode: the further you push, the faster it turns, and it stays put when you let go.
panAngle += axis(JOY_X, centerX) * MAX_SPEED * dt;
tiltAngle += axis(JOY_Y, centerY) * MAX_SPEED * dt;
}
panAngle = constrain(panAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
tiltAngle = constrain(tiltAngle, (float)MIN_ANGLE, (float)MAX_ANGLE);
pan.write((int)roundf(panAngle));
tilt.write((int)roundf(tiltAngle));
if (now - lastPrint >= 150) {
lastPrint = now;
Serial.printf("pan = %d, tilt = %d\n", (int)roundf(panAngle), (int)roundf(tiltAngle));
}
delay(15);
}Parts
| Quantity | Part | Where to buy |
|---|---|---|
| 1× | ESP32 dev board | Buy at Amazon |
| 1× | Analog thumb joystick with select button | Buy at Adafruit |
| 2× | Micro servos, SG90 or similar | Buy at Adafruit |
| 1× | Optional: MPU-6050 board (GY-521) for tilt steering | |
| 1× | Optional: a pan-tilt bracket, or tape the tilt servo to the pan servo's horn | |
| 10× | Jumper wires |
Amazon buy links may include an Associates tag, and this site may earn a commission from qualifying Amazon purchases at no extra cost to you. Adafruit links are direct.
Libraries
Wire (built in), ESP32Servo
Load it
- In the Arduino IDE, install the esp32 board package and pick the board named above the sketch.
- Install the libraries listed above from the Library Manager.
- Paste the sketch, choose the board's port and upload. Hold BOOT if the upload stalls at Connecting.
- Close the IDE's serial monitor, then open the bench here to watch the output.
Field notes
- Leave the stick alone while the board starts: it reads the resting position as the centre, so the turret does not creep.
- In stick mode the turret moves faster the further you push and stays where it is when you let go. Change MAX_SPEED to taste, and DEADZONE if it drifts with the stick at rest.
- Without an MPU-6050 the stick's button does nothing. With one, a click switches to tilt mode, where the servos copy the board's tilt: hold it like a remote.
- Many joystick modules are labelled +5V. Power them from 3V3 anyway: the stick is just two potentiometers, and at 5 V their outputs could reach 5 V.
- On the C3 the two axes use GPIO 0 and 1, the only free ADC pins once I2C and the servos are wired.
Not tested yet
- Not tested on hardware yet. Compiles for every board listed with Arduino-ESP32 core 3.3.12.
Community builds
Loading build reports from people who made this.
Built it? Send a build report with your board and what the serial port printed. Working reports become the "Tested on this board" entries above.