Skip to content

Lab 5: Buttons and Interaction

Time: ~45 minutes | Prerequisites: Lab 4 | Hardware: Pico 2, OLED, two push buttons

Now it can listen to your fingers

Echo waving welcome Your Pico can show you things. Now let's let you talk back. Reading a button sounds trivial — and then you discover that one press can register as five. Let's tune in.

What You'll Build

A three-mode counter you drive with two buttons: one cycles the mode, the other applies it. Along the way you'll meet the sneakiest bug in embedded programming — switch bounce.

Learning Objectives

  • Wire a push button using an internal pull-up resistor
  • Explain why a pressed button reads 0 and not 1
  • Describe switch bounce and why it breaks naive code
  • Implement debouncing with a time window
  • Detect the moment a button is pressed rather than whether it's held
  • Build an event loop that switches between modes

Concepts Introduced

ID Concept
240 Digital Input
241 Pull Up Resistor
242 Active Low Logic
243 Switch Bounce
244 Debouncing
245 Polling Loop
246 Edge Detection
247 Event Loop
248 Mode Switching

Background

A floating pin is a liar

An input pin not connected to anything doesn't read 0. It reads whatever the nearby electrical noise says, flickering randomly. That's a floating pin.

The fix is a pull-up resistor: a gentle connection to 3.3 V that holds the pin at 1 whenever nothing else is driving it. The Pico has these built in — Pin.PULL_UP switches one on.

So we wire each button between its pin and ground:

Button state Pin connected to Reads
not pressed pull-up → 3.3 V 1
pressed ground 0

That's active low: pressed is 0. Backwards from intuition, universal in practice.

Switch bounce: one press, many presses

Here's the part that surprises people. A button is two pieces of metal springing together. For a few milliseconds after contact they physically bounce apart and back:

1
2
3
4
5
6
button pressed here
        ↓
   1 ───┐ ┌─┐ ┌───────────  ← electrically, this is what the pin sees
        └─┘ └─┘
   0        ↑
        bouncing

Your finger felt one press. Your Pico, checking millions of times a second, saw five.

Why software fixes a hardware problem

Echo thinking You could fix bounce with a capacitor. But bounce settles within about 20 ms, and no human presses a button twice in 20 ms — so we just ignore any change that arrives too soon after the last one. Free, adjustable, no extra parts. Debouncing.

Edges, not levels

There's a difference between "is the button down?" and "did the button just go down?"

If your loop runs 200 times a second and you check level, holding the button for one second counts 200 presses. You want the edge — the single moment it changed from 1 to 0.

You detect that by remembering what it looked like last time:

1
2
def pressed(pin, last_value):
    return last_value == 1 and pin.value() == 0

Wiring

Each button connects its GPIO pin to GND. No resistor needed — the pull-up is internal.

Button GPIO Other leg
A 14 GND
B 15 GND

A typical 4-pin tactile switch connects pins diagonally. If it seems permanently pressed, rotate it 90°.

Procedure

Step 1 — Watch a button, raw

Before running the full lab, try this in the REPL:

1
2
3
import config
a, b = config.init_buttons()
a.value()          # 1 when you're not touching it

Hold the button down and run a.value() again. It reads 0. That's active low, live.

Step 2 — Run the lab

Open 05-buttons.py and run it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# Lab 5: Buttons and Interaction
#
# Two buttons, three modes, one counter. The interesting part is not reading
# a button -- it is reading it *correctly*.
#
# A mechanical switch does not close cleanly. Its contacts physically bounce
# apart and together again for a few milliseconds, so one press can look like
# five presses to a chip running at 150 MHz. Debouncing is how we ignore that.

import config
import time

oled = config.init_display()
button_a, button_b = config.init_buttons()

MODES = ["Count up", "Count down", "Count by 5"]
mode = 0
counter = 0

# Remember what each button looked like last time round the loop, so we can
# spot the *moment* it changes rather than the fact that it is held down.
last_a = button_a.value()
last_b = button_b.value()
last_press_ms = time.ticks_ms()

DEBOUNCE_MS = 50        # ignore changes closer together than this


def pressed(pin, last_value):
    """True on the falling edge: the moment the button goes 1 -> 0.

    PULL_UP means the pin sits at 1 and a press drags it to 0, so a press is
    a FALLING edge. This trips up nearly everyone the first time.
    """
    return last_value == 1 and pin.value() == 0


def draw():
    oled.fill(config.BLACK)
    oled.text("Lab 5: Buttons", 0, 0, config.WHITE)
    oled.hline(0, 10, config.WIDTH, config.WHITE)
    oled.text("Mode:", 0, 18, config.WHITE)
    oled.text(MODES[mode], 0, 30, config.WHITE)
    oled.text("Count: %d" % counter, 0, 46, config.WHITE)
    oled.text("A=mode B=go", 0, 56, config.WHITE)
    oled.show()


draw()
print("A changes mode, B applies it. Ctrl-C to stop.")

try:
    while True:
        now = time.ticks_ms()
        # Has enough time passed since the last accepted press?
        settled = time.ticks_diff(now, last_press_ms) > DEBOUNCE_MS

        if settled and pressed(button_a, last_a):
            mode = (mode + 1) % len(MODES)
            last_press_ms = now
            print("mode ->", MODES[mode])
            draw()

        if settled and pressed(button_b, last_b):
            if mode == 0:
                counter += 1
            elif mode == 1:
                counter -= 1
            else:
                counter += 5
            last_press_ms = now
            print("count ->", counter)
            draw()

        last_a = button_a.value()
        last_b = button_b.value()

        time.sleep_ms(5)      # polling every 5 ms is plenty for human fingers

except KeyboardInterrupt:
    oled.fill(config.BLACK)
    oled.text("Stopped.", 30, 28, config.WHITE)
    oled.show()
    print("Stopped.")
  • Button A cycles: Count up → Count down → Count by 5
  • Button B applies the current mode to the counter

Step 3 — Break it on purpose

Find this line and change the value to 0:

1
DEBOUNCE_MS = 50        # try 0

Run it again and press B once. Watch the counter jump by 2, 3, or more. That's bounce, and you just saw it with your own eyes.

Now put it back to 50. Try 5. Try 200 — notice the button starts feeling sluggish and ignores fast presses. Debounce timing is a real tradeoff, not a magic number.

If your counter jumps around, you haven't failed

Echo encouraging You've reproduced a bug that has shipped in real products. Seeing it deliberately, in a lab where nothing's at stake, is much nicer than discovering it in week ten of a project.

Step 4 — Levels versus edges

Replace the edge test with a level test:

1
if settled and button_b.value() == 0:      # level, not edge

Hold B down. The counter runs away. Restore the pressed() version and it takes one press per count. That's the difference an edge makes.

Expected Output

The display shows mode and count; the Shell logs each accepted press:

1
2
3
4
5
6
7
A changes mode, B applies it. Ctrl-C to stop.
count -> 1
count -> 2
mode -> Count by 5
count -> 7
mode -> Count down
count -> 6

Troubleshooting

Symptom Likely cause Fix
Reads 0 constantly Button orientation Rotate the switch 90°; pins connect diagonally
Reads 1 always Not reaching ground Check the wire from the button's other leg to GND
Counter jumps several per press Debounce too short Raise DEBOUNCE_MS toward 50
Have to press very deliberately Debounce too long Lower DEBOUNCE_MS toward 30
Counter races while held Testing level, not edge Use the pressed() edge helper
Nothing on screen Display issue, not buttons Re-check Lab 4

Challenges

  1. Long press. Detect a button held for more than one second and use it to reset the counter to zero. Hint: record the time on the falling edge, check on the rising one.
  2. Both at once. Make pressing A and B together do something distinct.
  3. A real menu. Use A to move a > cursor down a list of three items and B to select. This is the pattern you'll want in Lab 24 to switch display modes on a live analyzer.

Check Your Understanding

  1. Why does a pressed button read 0 instead of 1?
  2. What would happen with no pull-up resistor at all?
  3. Describe switch bounce and one way to handle it.
  4. What's the difference between edge detection and level detection, and when does it matter?
  5. Why is a 50 ms debounce window reasonable — what sets the upper and lower bounds?

You've got input and output now

Echo celebrating Screen, buttons, and a chip that knows itself. One more setup lab and then — my favourite part — we plug in ears.


Next: Lab 6: Deploying Code and Libraries | Previous: Lab 4