Connecting an Analog Joystick to the Raspberry Pi

Connecting an Analog Joystick to the Raspberry Pi

One of the coolest things about the Raspberry Pi is its GPIO pins. They’re just sitting there, waiting to be connected to all kinds of useful peripherals so your Pi can interact with the world around it.

Power an LED to signal the user. Place a button in the path of a circuit and detect when a user presses it. Attach sensors to read temperature and humidity, and plug other cards like the Sense HAT over top of the pins.

A few months ago, I got a set of 37 sensor modules on Amazon. I knew they wouldn’t directly interface with the Pi, but that it was entirely possible to do it, so they were set aside for later. Well, I finally decided to pull one out, and thought the mini-joystick might offer some interesting opportunities. :)

Materials

There are a few things you’ll need on hand before doing this, all of which you can find on Amazon (of course).

  • Raspberry Pi Starter Kit
    A decent starter kit includes the Pi, adapter, memory card, case, breadboard and cobbler, wires and LEDs, etc. If you already have a Pi, obviously you don't need this. There's lots of options, like the Freenove complete starter kit that has generally good reviews, or their basic starter kit, or the CamJam EduKit (note that none of these comes with a Pi).
  • Long Breadboard
    Some of the kits come with a shorter breadboard. The longer boards (like these ones) give you more space to work, and allow for more wires, LEDs, switches, etc.
  • Kuman 37 Sensor Module Kit for Arduino
    It comes with a joystick control (which I used for this post), and a load of other sensors and input devices. There's no documentation, but I found a link to instructions for each module on Amazon.. it's still sparse though.
  • Phantom YoYo Jumper wire M/F male to female
    You’ll need a few of these to connect the joystick to the breadboard. I pulled 5 random wires out of the set to use on this project, and they all worked great.
  • Adafruit MCP3008 – 8-Channel 10-Bit ADC With SPI Interface [ADA856]
    A tiny chip that bridges the gap between an analog control and the Pi. It’s cheaper directly from Adafruit, but watch out for shipping. If you’re buying several instead of just one like me, consider Adafruit’s site. Here's the datasheet.

Interfacing with Analog Controls

The joystick is an analog control, consisting of two potentiometers that send a variable voltage depending on the position of the joystick (here’s a video that shows how they work), and it won’t just connect directly to the GPIO pins on the Pi. If your joystick can be pressed down like mine can, then *that *button just has an on/off state and can be connected directly to any regular GPIO pin. But I’ll wire it up same as the potentiometers, since that’s what the articles linked below do as well.

To get it to work, you’ll need to learn about the SPI bus protocol and how to enable it on the Pi, then wire up a small chip that uses SPI to bridge the gap between analog controls and the Pi. Fortunately, there’s a set of very helpful tutorials for doing just this, and you’ll find them on the Raspberry Pi Spy website.

First, you’ll learn how to enable the Serial Peripheral Interface (SPI) bus on certain GPIO pins. Method 1 worked fine for me – you just open up a config screen in Raspbian and select the SPI option.

Then you’ll need to wire up the MCP3008 chip correctly. That’ll provide the bridge between the joystick and your Pi. The “using a joystick” article linked above will walk you through it. You don’t *need *to read the “analogue sensors” article, but it’s got some helpful info in it that’s not in the other one. I suggest reading both.

Also, for changing colors on the RGB LED, you may want to learn more about pulse-width modulation (PWM).

Here are some pictures and a diagram of my setup, which hopefully will help if you get stuck, although Matt provides a good set of pics in his article too. There’s some additional stuff in my circuit that’s not in his, namely the RGB LED and resistors/wires to make it work. I used a 100Ω resistor for red and 220Ω for green and blue, same as here.

Fritzing Diagram

joystick-color-wheel

If you’d like, you can download the original Fritzing file and play around with it. The Fritzing site also has loads of diagrams that people have shared, which you can check out too. If you’re trying to connect a particular component, you might find something in there to help you out.

Reading Input

You should’ve already verified that Python Spidev (pi-spydev) was installed after you enabled SPI. We’ll need that for reading input from the analog device.

Since I’ve been messing with an RGB LED lately, I thought it’d be interesting to map the position of the joystick to the RGB color wheel and then light up the LED appropriately. Imagine the X-axis running horizontal above Blue and Green, and the Y-axis running vertical through Red.

rgb_color_wheel_400px

Here’s the code in its entirety, or check it out on GitHub.

import math
import RPi.GPIO as GPIO
import spidev
 
# Open SPI bus
spi = spidev.SpiDev()
spi.open(0, 0)
 
# Define sensor channels (3 to 7 are unused)
mcp3008_switch_channel = 0
mcp3008_x_voltage_channel = 1
mcp3008_y_voltage_channel = 2
 
# Define RGB channels
red_led = 36
green_led = 31
blue_led = 37
 
 
def read_spi_data_channel(channel):
    """
    Read in SPI data from the channel and return a coordinate position
    :param channel: integer, between 0-7
    :return: integer, between 0-1023 indicating joystick position
    """
 
    adc = spi.xfer2([1, (8+channel) << 4, 0])
    return ((adc[1] & 3) << 8) + adc[2]
 
 
def convert_coordinates_to_angle(x, y, center_x_pos, center_y_pos):
    """
    Convert an x,y coordinate pair representing joystick position,
    and convert it to an angle relative to the joystick center (resting) position
    :param x: integer, between 0-1023 indicating position on x-axis
    :param y: integer, between 0-1023 indicating position on y-axis
    :param center_x_pos: integer, indicating resting position of joystick along x-axis
    :param center_y_pos: integer, indicating resting position of joystick along y-axis
    :return: integer, between 0-359 indicating angle in degrees
    """
 
    dx = x - center_x_pos
    dy = y - center_y_pos
    rads = math.atan2(-dy, dx)
    rads %= 2 * math.pi
    return math.degrees(rads)
 
 
def adjust_angle_for_perspective_of_current_led(angle, led):
    """
    Take the current LED into account, and rotate the coordinate plane 360 deg to make PWM calculations easier
    :param angle: integer, between 0-359 indicating current angle of joystick position
    :param led: 'R', 'G', 'B', indicating the LED we're interested in
    :return: integer, between 0-359 indicating new angle relative to the current LED under consideration
    """
 
    led_peak_angle = 90 if led == 'R' else (210 if led == 'B' else 330)
    return ((angle - led_peak_angle) + 360) % 360
 
 
def calculate_next_pwm_duty_cycle_for_led(angle, led):
    """
    Calculate the next PWM duty cycle value for the current LED and joystick position (angle)
    :param angle: integer, between 0-359 indicating current angle of joystick position
    :param led: 'R', 'G', 'B', indicating the LED we're interested in
    :return: integer, between 0-100 indicating the next PWM duty cycle value for the LED
    """
 
    angle = adjust_angle_for_perspective_of_current_led(angle, led)
    if 120 < angle < 240:
        return 0
    elif angle <= 120:
        return 100 - (angle * (100 / 120.0))
    else:
        return 100 - ((360 - angle) * (100 / 120.0))
 
 
def is_joystick_near_center(x, y, center_x_pos, center_y_pos):
    """
    Compare the current joystick position to resting position and decide if it's close enough to be considered "center"
    :param x: integer, between 0-1023 indicating position on x-axis
    :param y: integer, between 0-1023 indicating position on y-axis
    :param center_x_pos: integer, indicating resting position of joystick along x-axis
    :param center_y_pos: integer, indicating resting position of joystick along y-axis
    :return: boolean, indicating whether or not the joystick is near the center (resting) position
    """
 
    dx = math.fabs(x - center_x_pos)
    dy = math.fabs(y - center_y_pos)
    return dx < 20 and dy < 20
 
 
def main():
    """
    Initializes GPIO and PWM, then sets up a loop to continually read the joystick position and calculate the next set
    of PWM value for the RGB LED. When user hits ctrl^c, everything is cleaned up (see 'finally' block)
    :return: None
    """
 
    # Center positions when joystick is at rest
    center_x_pos = 530
    center_y_pos = 504
 
    GPIO.setmode(GPIO.BOARD)
    GPIO.setup([red_led, green_led, blue_led], GPIO.OUT, initial=GPIO.LOW)
 
    pwm_r = GPIO.PWM(red_led, 300)
    pwm_g = GPIO.PWM(green_led, 300)
    pwm_b = GPIO.PWM(blue_led, 300)
 
    pwm_instances = [pwm_r, pwm_g, pwm_b]
 
    for p in pwm_instances:
        p.start(0)
 
    try:
        while True:
            # If joystick switch is pressed down, turn off LEDs
            switch = read_spi_data_channel(mcp3008_switch_channel)
            if switch == 0:
                for p in pwm_instances:
                    p.ChangeDutyCycle(0)
                continue
 
            # Read the joystick position data
            x_pos = read_spi_data_channel(mcp3008_x_voltage_channel)
            y_pos = read_spi_data_channel(mcp3008_y_voltage_channel)
 
            # If joystick is at rest in center, turn on all LEDs at max
            if is_joystick_near_center(x_pos, y_pos, center_x_pos, center_y_pos):
                for p in pwm_instances:
                    p.ChangeDutyCycle(100)
                continue
 
            # Adjust duty cycle of LEDs based on joystick position
            angle = convert_coordinates_to_angle(x_pos, y_pos, center_x_pos, center_y_pos)
            pwm_r.ChangeDutyCycle(calculate_next_pwm_duty_cycle_for_led(angle, 'R'))
            pwm_g.ChangeDutyCycle(calculate_next_pwm_duty_cycle_for_led(angle, 'G'))
            pwm_b.ChangeDutyCycle(calculate_next_pwm_duty_cycle_for_led(angle, 'B'))
 
            # print("Position : ({},{})  --  Angle : {}".format(x_pos, y_pos, round(angle, 2)))
 
    except KeyboardInterrupt:
        pass
 
    finally:
        for p in pwm_instances:
            p.stop()
        spi.close()
        GPIO.cleanup()
 
 
if __name__ == '__main__':
    main()

I’ve done more work than I usually do to comment the code, so the inputs, outputs, and purpose of these functions are as clear as possible.

Always Write Tests!

If you check out my joystick project repo on GitHub, you’ll see a separate file with tests in it. Testing your code is vitally important. I found a bug in calculate_next_pwm_duty_cycle_for_led where I was performing integer division by accident, unintentionally discarding the fractional part of the result, which would’ve thrown everything off and been tough to track down.

If you’re an aspiring developer, get in the habit now. To run the tests in this project, you’ll need to install the DDT (Data-Driven Tests) package for Python unit testing via pip.

General Comments

I wrote the adjust_angle_for_perspective_of_current_led function to make calculations easier. Imagine a 360 degree circle overlaying the color wheel. Each color (red, blue, green) is separated by 120 degrees. So if red is at 90 (the top), then blue is at 210 and green is at 330. That function rotates the imaginary circle, placing the LED we’re concerned about at 0 degrees.

The is_joystick_near_center function was necessary because the joystick is not that accurate. Even when it’s sitting still, the readings coming off it fluctuate a bit. That’s not a huge deal when the joystick is positioned far away from the center, but imagine what happens when the position is near center and the X and Y coordinates keep jumping around the vertex of our “angle”. The angle varies wildly, so that when the joystick is “at rest”, the color flickers all over the place on the LED.

So instead, I just display white if you’re near center.

See it in Action

If you have a question about any of the code, leave a comment below and I’ll try to clarify. There’s one tricky piece in the read_spi_data_channel() function, and that’s the call to spi.xfer2(). Suffice to say, that’s the pi-spydev module doing work.

If you want, check out the spidev_module.c file and do a search for “xfer2″. It’s roughly 100 lines of C code.