Lab 47: Buttons Move a Light
Pixel says...
Until now, the patterns moved all by themselves. This time you're in the driver's seat! Press a button and the light hops where you tell it.
Program file: 47-buttons-move-light.py
What you'll learn
- How to keep a light's place in a variable named
position - How a button interrupt changes
positionwhile the main loop keeps running - How
%makes the position wrap from one end of the strip to the other - How to redraw the strip only when something has changed
What you'll need
- Your base kit, including both push buttons wired as shown in The Two Buttons. Each button reads
0while you press it. - The
config.pyfile saved on the Pico (see Getting Code onto the Kit) - Thonny open and connected to your Pico
- Lab 34: Two Buttons first. It teaches the interrupt and debounce pattern that this lab uses again.
The program
This program lights one dim red pixel in the middle of the strip. Button 1 moves it up one pixel and Button 2 moves it down one pixel.
Full program: 47-buttons-move-light.py (98 lines)
# Lab 47: Buttons Move a Light
# Filename: 47-buttons-move-light.py
# Version: 1.0.0
#
# Use the buttons to move a light up and down the strip.
from machine import Pin
from neopixel import NeoPixel
from utime import sleep, ticks_ms
from urandom import randint
import config
# hardware settings from config.py
NEOPIXEL_PIN = config.NEOPIXEL_PIN
NUMBER_PIXELS = config.NUMBER_PIXELS
BUTTON_PIN_1 = config.BUTTON_PIN_1 #Button 1 uses pin 15 on the Raspberry Pi circuit board (closer to board)
BUTTON_PIN_2 = config.BUTTON_PIN_2 #Button 2 uses pin 14 on the Raspberry Pi circuit board (closer to edge)
# the LED built onto the Pico board (not set in config.py)
BUILT_IN_LED_PIN = 25
strip = NeoPixel(Pin(NEOPIXEL_PIN), NUMBER_PIXELS)
button1_presses = 0 # the count of times the button has been pressed
button2_presses = 0
last_time = 0 # the last time we pressed the button
builtin_led = Pin(BUILT_IN_LED_PIN, Pin.OUT)
# The lower left corner of the Pico has a wire that goes through the buttons upper left and the lower right goes to the 3.3 rail
button1 = Pin(BUTTON_PIN_1, Pin.IN, Pin.PULL_UP)
button2 = Pin(BUTTON_PIN_2, Pin.IN, Pin.PULL_UP)
#Most of these color definitions are not needed here, but they came with the "copy/paste" of code I used
red = (255, 0, 0)
red_med = (32, 0, 0)
red_light = (8, 0, 0)
off = (0, 0, 0)
orange = (140, 60, 0)
yellow = (255, 255, 0)
green = (0, 255, 0)
blue = (0, 0, 255)
cyan = (0, 255, 255)
indigo = (75, 0, 130)
violet = (138, 43, 226)
white = (128, 128, 128)
colors = (red, orange, yellow, green, blue, cyan, indigo, violet)
color_count = len(colors)
HALF_LENGTH = round(NUMBER_PIXELS/2)
position = HALF_LENGTH
# This function gets called every time the button is pressed. The parameter "pin" is used to tell
# which pin is used
def button_pressed_handler(pin):
# global mode, last_time
global position, last_time, button1_presses, button2_presses
new_time = ticks_ms()
# if it has been more than 1/5 of a second since the last event, we have a new event
if (new_time - last_time) > 200:
# pin is the button object that triggered the interrupt
if pin == button1:
position +=1
button1_presses +=1
else: #we know that a button has been pressed, and it's not button 1, so it must be button 2
position -=1
button2_presses +=1
# wrap around if we get too high
position = position % NUMBER_PIXELS
last_time = new_time
print('button 1 pressed ', button1_presses, 'times; button 2 pressed', button2_presses, 'times')
# now we register the handler function when the button is pressed
button1.irq(trigger=Pin.IRQ_FALLING, handler = button_pressed_handler)
button2.irq(trigger=Pin.IRQ_FALLING, handler = button_pressed_handler)
# erase the entire strip
def erase():
for i in range(0, NUMBER_PIXELS):
strip[i] = (0,0,0)
def move_dot(lp, counter, color, delay):
strip[lp] = (0,0,0)
strip[counter] = color
strip.write()
sleep(delay)
erase()
last_position = 0
while True:
if position != last_position:
move_dot(last_position, position, red_med, .1)
last_position = position
Run it. A dim red light appears at pixel 15. Each press of Button 1 moves it up one pixel, and each press of Button 2 moves it down one pixel. The Shell counts your presses, like this:
button 1 pressed 3 times; button 2 pressed 1 times
How it works
The position is a variable
In earlier labs, a loop counter chose the pixel. Here, position is a plain variable. The buttons change it, and the main loop draws the light wherever position says.
HALF_LENGTH = round(NUMBER_PIXELS/2)
position = HALF_LENGTH
round(NUMBER_PIXELS/2) is 15, so the light starts at pixel 15, near the middle of the strip.
The button handler
An interrupt is a signal that makes the Pico pause what it is doing, run a small function, and then go back to its work. The small function is called a handler. These two lines connect both buttons to the handler.
button1.irq(trigger=Pin.IRQ_FALLING, handler = button_pressed_handler)
button2.irq(trigger=Pin.IRQ_FALLING, handler = button_pressed_handler)
IRQ_FALLING means "run the handler when the pin's value falls from 1 to 0." That is the moment you press a button, because a pressed button reads 0.
The handler starts with a debounce check. A button's metal contacts can bounce for a moment, so one press can look like several. The check ignores any press that comes less than 200 milliseconds after the last one.
def button_pressed_handler(pin):
global position, last_time, button1_presses, button2_presses
new_time = ticks_ms()
# if it has been more than 1/5 of a second since the last event, we have a new event
if (new_time - last_time) > 200:
The global line lets the handler change variables that live outside of it. ticks_ms() gives the number of milliseconds since the Pico started. So the light can move at most five times each second.
The handler gets a pin value that says which button was pressed. Button 1 moves the position up by one. Button 2 moves it down by one. Each button also keeps its own press count.
if pin == button1:
position +=1
button1_presses +=1
else: #we know that a button has been pressed, and it's not button 1, so it must be button 2
position -=1
button2_presses +=1
The last lines of the handler wrap the position, remember the time, and print the counts.
# wrap around if we get too high
position = position % NUMBER_PIXELS
last_time = new_time
print('button 1 pressed ', button1_presses, 'times; button 2 pressed', button2_presses, 'times')
Wrap around with %
The strip has pixels 0 to 29, but position can step past either end. The modulo operator % gives the remainder after dividing, and that brings the number back onto the strip.
| Button press | position before % |
position after % 30 |
|---|---|---|
| Button 1 at pixel 16 | 17 | 17 |
| Button 1 at pixel 29 | 30 | 0 |
| Button 2 at pixel 0 | -1 | 29 |
So pressing Button 1 at the top of the strip sends the light to pixel 0. Pressing Button 2 at pixel 0 sends it to pixel 29. The comment in the code only mentions "too high", but the same line also fixes "too low". The Modulo Wrap-Around Animation shows the idea in motion.
Draw only when something changed
This function moves the light. Its inputs are the old place (lp), the new place (counter), a color, and a pause in seconds.
def move_dot(lp, counter, color, delay):
strip[lp] = (0,0,0)
strip[counter] = color
strip.write()
sleep(delay)
It does four things in order. It erases the old pixel in memory. It sets the new pixel's color in memory. It calls strip.write() once to send both changes. Then it waits.
The main loop uses move_dot like this.
erase()
last_position = 0
while True:
if position != last_position:
move_dot(last_position, position, red_med, .1)
last_position = position
erase() clears every pixel in memory, so the strip starts dark. The loop spins thousands of times a second. The test position != last_position makes it draw only when a button has changed position. Then last_position = position remembers where the light is now.
At the start, position is 15 and last_position is 0. They are different, so the first pass through the loop draws the light at pixel 15.
Lines you can ignore
Some lines near the top came along from other programs and do nothing here. They are randint, the builtin_led pin, and every color except red_med. A comment in the file says the same. Leaving them alone is fine.
Try it yourself
- In the line
move_dot(last_position, position, red_med, .1), changered_medto(0, 32, 0). What color is the light now? - Change
position = HALF_LENGTHtoposition = 0. Run it. No light appears until you press a button. Why? Hint: look atlast_positionand theifin the main loop.
Check your understanding
- What does Button 1 do to
position? What does Button 2 do? - What does
position % NUMBER_PIXELSgive whenpositionis 30? What about when it is -1? - Why does the main loop compare
positionwithlast_position? - Why does the handler ignore a press that comes less than 200 milliseconds after the last one?
- Name the four steps that
move_dotdoes, in order.
Lab complete!
You built a real controller! Your buttons now steer a light the way a game controller steers a character.
What's next: In Lab 48: Pixel Demo, the same two buttons pick between twelve light patterns.