Built-in project · ESP32-C3-DevKitM-1

ESP32-C3 thumb stick

A thumb stick on the ESP32-C3 ADC: the raw axes go to the serial monitor and how far the stick is pushed sets how brightly the LED on that side is lit. The whole build, an ESP32-C3 and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 20 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $14.80. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Reading an analog voltage with the ESP32-C3 ADC
  • Why a stick needs a measured rest position
  • Dead zones for a stick that is not perfectly centered
  • Making an LED dimmer by switching it fast, by hand
  • Reading a stick's push-down switch
  • Difficulty: Intermediate
  • Time: About 20 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 7

How it works

The thumb stick has two potentiometers, one per axis. Each gives a voltage between 0 and 3.3 V, which the ESP32-C3 reads on pins 0 and 1 as a number from 0 to 4095. Pressing the stick down closes the switch on pin 3.

At startup the library samples where the stick rests, since no stick is perfectly centered. After that the sketch works with the offset from that rest point, with a small dead zone so a stick that is slightly off does not creep.

Push left and the LED on pin 6 lights. Push right and the LED on pin 7 lights. The farther you push, the brighter it gets. Press the stick down and both light fully.

Parts list

7 parts, plus the jumper wires. They retail for about $14.80; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Thumb stick pin VCCESP32-C3-DevKitM-1 pin 3V3
Thumb stick pin SWESP32-C3-DevKitM-1 pin 3
Thumb stick pin GNDESP32-C3-DevKitM-1 pin GND
LED, green (1) pin CESP32-C3-DevKitM-1 pin GND
LED, green (2) pin CESP32-C3-DevKitM-1 pin GND
Thumb stick pin VRXESP32-C3-DevKitM-1 pin 0
Thumb stick pin VRYESP32-C3-DevKitM-1 pin 1
Resistor, 220 Ω (1) pin 1ESP32-C3-DevKitM-1 pin 6
Resistor, 220 Ω (2) pin 1ESP32-C3-DevKitM-1 pin 7
LED, green (1) pin AResistor, 220 Ω (1) pin 2
LED, green (2) pin AResistor, 220 Ω (2) pin 2

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
// Thumb stick: an ESP32-C3 reading a KY-023 on ADC1, out loud.
//
// Wiring, module to board:
//   VRX -> GPIO 0     VRY -> GPIO 1     SW -> GPIO 3
//   VCC -> 3V3        GND -> GND
// Two LEDs, each through 220 R to ground:
//   left -> GPIO 6    right -> GPIO 7
//
// Push the stick left or right and the LED on that side lights; push it far
// enough and it lights brightly rather than dimly, because the sketch bit-bangs
// a duty from how far the stick has gone. Press the stick down and both come
// on. Every time the reading changes, the raw pair and the offsets from center
// are printed on the serial monitor.
//
// # This is what the ADC is for
//
// GPIO 0 to GPIO 4 are ADC1 channels 0 to 4 (docs/esp32c3.md section 3.5), and
// analogRead on one of them is a real conversion of the real net voltage: the
// two pots inside the module sit across the 3.3 V rail, so each axis is a
// voltage between the rails and comes back as 0..4095 at the default 11 dB
// attenuation. Nothing here is a threshold, which is the whole difference
// between a stick and two buttons.
//
// # Why the numbers are not 0, 2048, 4095
//
// A KY-023's pots do not reach the rails: the ends of the track are a little
// way inside them, and the spring does not center perfectly either. So the
// sketch does not assume a center; mokxi_joystick.h samples the rest position
// in begin() and reports offsets from it, with a dead zone so a stick that is
// merely not perfectly centered does not creep. Move the stick before reset and
// the readings will be off, which is true of the hardware too.

#include "mokxi_joystick.h"

const uint8_t VRX_PIN = 0;
const uint8_t VRY_PIN = 1;
const uint8_t SW_PIN = 3;
const uint8_t LEFT_LED = 6;
const uint8_t RIGHT_LED = 7;

Joystick stick(VRX_PIN, VRY_PIN, SW_PIN);

// The PWM the two LEDs are lit at: 64 steps of 62 us is a 252 Hz frame, fast
// enough that the eye sees a brightness rather than a flicker, and the board
// has nothing else to do between conversions. 62 us clears the runtime's own
// 50 us park threshold (firmware/src/time.c): a step under that spins on real
// instructions to stay accurate at that scale, and this sketch has nothing
// that fine to be accurate about, so every step now parks the core in `wfi`
// between edges instead of burning cycles being briefly, needlessly precise.
const uint8_t STEPS = 64;
const uint16_t STEP_US = 62;

int16_t lastX = -1;
int16_t lastY = -1;

/** An offset from the rest position with the dead zone taken out of it. */
static int16_t deadzoned(int16_t offset)
{
  if (offset > JOYSTICK_DEADZONE) {
    return (int16_t)(offset - JOYSTICK_DEADZONE);
  }
  if (offset < -JOYSTICK_DEADZONE) {
    return (int16_t)(offset + JOYSTICK_DEADZONE);
  }
  return 0;
}

/** How brightly one side should be lit, 0..STEPS, from an axis offset. */
static uint8_t dutyOf(int16_t offset)
{
  if (offset <= 0) {
    return 0;
  }
  // Full scale less the dead zone is the travel that is left; map it onto the
  // steps with a shift rather than a divide, since the numbers are powers of
  // two either side of it.
  int32_t span = (JOYSTICK_FULL_SCALE / 2) - JOYSTICK_DEADZONE;
  int32_t duty = ((int32_t)offset * STEPS) / span;
  return (uint8_t)(duty > STEPS ? STEPS : duty);
}

/** One PWM frame with the two sides at their own duties. */
static void showFrame(uint8_t left, uint8_t right)
{
  for (uint8_t i = 0; i < STEPS; i++) {
    digitalWrite(LEFT_LED, i < left ? HIGH : LOW);
    digitalWrite(RIGHT_LED, i < right ? HIGH : LOW);
    delayMicroseconds(STEP_US);
  }
  digitalWrite(LEFT_LED, LOW);
  digitalWrite(RIGHT_LED, LOW);
}

void setup()
{
  Serial.begin(115200);
  pinMode(LEFT_LED, OUTPUT);
  pinMode(RIGHT_LED, OUTPUT);
  stick.begin();

  Serial.println("Mokxi ESP32-C3: a thumb stick on ADC1");
  Serial.print("resting at x=");
  Serial.print(stick.restX());
  Serial.print(" y=");
  Serial.print(stick.restY());
  Serial.print(" of ");
  Serial.println(JOYSTICK_FULL_SCALE);
}

void loop()
{
  int16_t x = stick.x();
  int16_t y = stick.y();
  int16_t dx = (int16_t)(x - stick.restX());
  int16_t dy = (int16_t)(y - stick.restY());

  // A conversion of a real net has no noise in it, so a changed reading means
  // the stick moved and every line printed is worth reading.
  if (x != lastX || y != lastY) {
    lastX = x;
    lastY = y;
    Serial.print("x=");
    Serial.print(x);
    Serial.print(" y=");
    Serial.print(y);
    Serial.print("  dx=");
    Serial.print(dx);
    Serial.print(" dy=");
    Serial.print(dy);
    if (stick.pressed()) {
      Serial.print("  pressed");
    }
    Serial.println();
  }

  if (stick.pressed()) {
    showFrame(STEPS, STEPS);
    return;
  }
  // The axes are already in hand: asking the Joystick again would be two more
  // conversions for numbers this loop has read once.
  int16_t swing = deadzoned(dx);
  showFrame(dutyOf((int16_t)(-swing)), dutyOf(swing));
}

Code walkthrough

The key functions of the sketch, and what each one does.

deadzoned sketch.ino

Takes an offset from the rest position and removes the dead zone from it. Anything inside JOYSTICK_DEADZONE becomes 0. Beyond it, the offset is reduced by the dead zone so the value grows smoothly from 0.

/** An offset from the rest position with the dead zone taken out of it. */
static int16_t deadzoned(int16_t offset)
{
  if (offset > JOYSTICK_DEADZONE) {
    return (int16_t)(offset - JOYSTICK_DEADZONE);
  }
  if (offset < -JOYSTICK_DEADZONE) {
    return (int16_t)(offset + JOYSTICK_DEADZONE);
  }
  return 0;
}

dutyOf sketch.ino

Turns an offset into a brightness from 0 to STEPS, which is 64. Negative offsets give 0, so each LED only responds to its own side. Larger pushes give a bigger duty, capped at STEPS.

/** How brightly one side should be lit, 0..STEPS, from an axis offset. */
static uint8_t dutyOf(int16_t offset)
{
  if (offset <= 0) {
    return 0;
  }
  // Full scale less the dead zone is the travel that is left; map it onto the
  // steps with a shift rather than a divide, since the numbers are powers of
  // two either side of it.
  int32_t span = (JOYSTICK_FULL_SCALE / 2) - JOYSTICK_DEADZONE;
  int32_t duty = ((int32_t)offset * STEPS) / span;
  return (uint8_t)(duty > STEPS ? STEPS : duty);
}

showFrame sketch.ino

Draws one brightness frame by hand. It steps through STEPS slices of STEP_US microseconds. Each LED is on while the step number is below its duty, then both go off at the end.

/** One PWM frame with the two sides at their own duties. */
static void showFrame(uint8_t left, uint8_t right)
{
  for (uint8_t i = 0; i < STEPS; i++) {
    digitalWrite(LEFT_LED, i < left ? HIGH : LOW);
    digitalWrite(RIGHT_LED, i < right ? HIGH : LOW);
    delayMicroseconds(STEP_US);
  }
  digitalWrite(LEFT_LED, LOW);
  digitalWrite(RIGHT_LED, LOW);
}

loop sketch.ino

Reads both axes and works out the offsets. When a reading changes, it prints the raw and offset values. If the stick is pressed, both LEDs go full. Otherwise it uses the left and right swing to set each LED's duty.

void loop()
{
  int16_t x = stick.x();
  int16_t y = stick.y();
  int16_t dx = (int16_t)(x - stick.restX());
  int16_t dy = (int16_t)(y - stick.restY());

  // A conversion of a real net has no noise in it, so a changed reading means
  // the stick moved and every line printed is worth reading.
  if (x != lastX || y != lastY) {
    lastX = x;
    lastY = y;
    Serial.print("x=");
    Serial.print(x);
    Serial.print(" y=");
    Serial.print(y);
    Serial.print("  dx=");
    Serial.print(dx);
    Serial.print(" dy=");
    Serial.print(dy);
    if (stick.pressed()) {
      Serial.print("  pressed");
    }
    Serial.println();
  }

  if (stick.pressed()) {
    showFrame(STEPS, STEPS);
  // ... 7 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The LEDs light up or the offsets are off when nothing is touched This project
    The rest position is sampled in setup. Keep the stick centered when the board resets.
  • Left and right seem swapped This project
    Check that VRX goes to pin 0 and the LEDs to pins 6 (left) and 7 (right).
  • No LED lights at all This project
    Each LED needs its 220 Ω resistor on the pin side and its cathode to ground.
  • The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
    Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts.
  • The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
    Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference.
  • The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
    Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure.
  • A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
    Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground.
  • The LED does not light, or it lights once and then dies. LED
    LEDs have polarity. The longer leg (anode) goes toward the positive side. Always add a series resistor, about 220 ohms to 1 k ohm on a 5 V pin.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.