Skip to content

PWM, Motor Speed Control, and Actuators

Welcome, maker — let's make me roll!

Sparky waving We've assembled the hardware, learned the language, and understood the electronics. Now it's time for the payoff: writing code that actually makes the wheels spin. By the end of this chapter, you'll control motor speed, make precise turns, sweep a servo, and play tones on the buzzer. Let's make it roll!

Summary

This chapter unlocks smooth, programmable motion. Students master Pulse Width Modulation — from duty cycle and frequency fundamentals through 16-bit values — and apply it to control motor speed, achieve differential drive (turning by varying wheel speeds), calibrate servo angles, and generate musical tones with the piezo buzzer. The chapter closes with GPIO interrupts and button debouncing, enabling event-driven robot responses.

Concepts Covered

This chapter covers the following 20 concepts from the learning graph:

  1. Pulse Width Modulation
  2. PWM Duty Cycle
  3. PWM Frequency
  4. 16-Bit Duty Cycle Values
  5. Motor Speed Control
  6. PWM Motor Control Code
  7. Left Motor Control
  8. Right Motor Control
  9. Differential Drive
  10. Servo Motor
  11. Servo Angle Range
  12. Servo PWM Calibration
  13. Servo Sweep Code
  14. Linear Range Mapping
  15. Piezo Buzzer
  16. Tone Frequency Control
  17. Sound Feedback
  18. GPIO Interrupt Setup
  19. IRQ Falling Edge
  20. Button Debouncing

Prerequisites

This chapter builds on concepts from:


Pulse Width Modulation

In Chapter 6 you learned that a GPIO pin is either HIGH (3.3 V) or LOW (0 V). That is digital — two states. But motor speed is not digital. You want to drive at 25%, 50%, 75%, or any percentage in between. How do you get a variable output from a digital pin?

The answer is Pulse Width Modulation, or PWM. PWM rapidly switches a pin between HIGH and LOW many times per second. Instead of changing the voltage level, it changes the proportion of time the pin is HIGH. That proportion is the duty cycle.

PWM Duty Cycle

The duty cycle is the percentage of each cycle that the signal is HIGH. If a signal is HIGH for half the cycle and LOW for half the cycle, the duty cycle is 50%. A 25% duty cycle is HIGH for one quarter of the cycle.

The average voltage delivered is proportional to the duty cycle. A 50% duty cycle on a 6 V motor supply delivers an average of 3 V to the motor. The motor's mechanical inertia smooths out the rapid switching, so it spins at approximately half speed.

Before the interactive diagram below, here are the three duty cycle values to keep in mind: 0% means always off (motor stopped), 100% means always on (full speed), and any value between controls speed proportionally.

Diagram: PWM Duty Cycle Explorer

Run PWM Duty Cycle Explorer Fullscreen

Interactive MicroSim showing PWM waveforms and average voltage

Type: MicroSim sim-id: pwm-duty-cycle-explorer
Library: p5.js
Status: Specified

Create a p5.js MicroSim with a 700 × 400 canvas split into two sections:

Top section (60% height): PWM waveform display. - Draw a square wave showing HIGH (3.3V) and LOW (0V) pulses. - An orange horizontal dashed line shows the "average voltage" level. - The waveform updates in real time as the duty cycle changes. - Label the HIGH time (Ton) and LOW time (Toff) with arrows.

Bottom section (40% height): Controls and stats. - A large horizontal slider "Duty Cycle" from 0% to 100%. - Numeric display: "Duty Cycle: 50%", "Average Voltage: 1.65 V", "Motor Speed: ~50%". - A small animated motor icon (spinning circle) whose rotation speed is proportional to the duty cycle.

Learning objective (Bloom's Taxonomy — Understanding): students connect duty cycle percentage to average voltage and motor speed.

Responsive: redraw on window resize.

PWM Frequency

The PWM frequency is how many times per second the signal completes one full HIGH-LOW cycle. Motor control typically uses 50–1000 Hz. At 50 Hz, the signal switches 50 times per second. At 1000 Hz, it switches 1000 times per second.

Higher frequencies feel smoother to the motor but require more processing. Lower frequencies can cause audible buzzing from the motor coils. For the DC motors on this robot, 50 Hz works reliably.

16-Bit Duty Cycle Values

MicroPython's PWM objects accept duty cycle as a 16-bit integer (0 to 65535), not a percentage. This gives finer control than 0–100 integers.

The conversion is simple. Before the code, here is the math: multiply the desired percentage (0.0 to 1.0) by 65535. So 50% is 0.5 * 65535 = 32767. Full speed is 65535. Stopped is 0.

Percent 16-bit value
0% (off) 0
25% 16383
50% 32767
75% 49151
100% (full) 65535

Motor Speed Control

Now let's wire PWM to the motors. In MicroPython, the machine.PWM class controls PWM output on any GPIO pin. Before the code, here is what the parameters mean: Pin(pin_number) selects the GPIO pin, and freq=50 sets 50 Hz.

1
2
3
4
5
6
from machine import PWM, Pin
import config

# Set up PWM for the right motor forward pin
right_fwd = PWM(Pin(config.RIGHT_FORWARD_PIN))
right_fwd.freq(50)   # 50 Hz motor frequency

To set a speed, call duty_u16() with a value from 0 to 65535:

1
2
3
right_fwd.duty_u16(32767)    # 50% speed
right_fwd.duty_u16(65535)    # full speed
right_fwd.duty_u16(0)        # stop

PWM Motor Control Code

A complete motor setup uses one PWM object per direction wire. Each DC motor has two direction wires: forward and reverse. To drive forward, we set the forward PWM to our desired speed and the reverse PWM to 0 (off). To drive in reverse, we swap them.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from machine import PWM, Pin
from time import sleep
import config

# Create PWM objects for both motor direction pins
right_fwd = PWM(Pin(config.RIGHT_FORWARD_PIN), freq=50)
right_rev = PWM(Pin(config.RIGHT_REVERSE_PIN), freq=50)
left_fwd  = PWM(Pin(config.LEFT_FORWARD_PIN),  freq=50)
left_rev  = PWM(Pin(config.LEFT_REVERSE_PIN),  freq=50)

def set_speed(pwm_fwd, pwm_rev, speed):
    """Set motor speed. Positive = forward, negative = reverse, 0 = stop.
    speed is -65535 to 65535.
    """
    if speed > 0:
        pwm_fwd.duty_u16(speed)
        pwm_rev.duty_u16(0)
    elif speed < 0:
        pwm_fwd.duty_u16(0)
        pwm_rev.duty_u16(-speed)
    else:
        pwm_fwd.duty_u16(0)
        pwm_rev.duty_u16(0)

Left Motor Control and Right Motor Control

Each wheel has its own motor and its own pair of PWM pins. Left motor control and right motor control work identically — they just use different pin numbers from config.py.

The set_speed() function above works for both motors. Call it twice — once for left, once for right:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FULL = 65535
HALF = 32767

# Drive forward at 50% speed
set_speed(right_fwd, right_rev, HALF)
set_speed(left_fwd,  left_rev,  HALF)
sleep(2)

# Stop
set_speed(right_fwd, right_rev, 0)
set_speed(left_fwd,  left_rev,  0)

Always stop both motors together

Sparky pointing up Call set_speed(..., 0) on both motors in your finally block (see Chapter 4). If your program crashes while one motor is running and the other is stopped, the robot will spin in circles until the battery dies. Stopping both motors in finally prevents this.


Differential Drive — Turning by Varying Speed

A two-wheeled robot turns by running the two wheels at different speeds. This is called differential drive. No steering wheel is needed.

Before the concept diagram, here is the idea: if both wheels run at the same speed, the robot goes straight. If the right wheel runs faster than the left, the robot turns left (pivoting around the slower wheel). If the left runs faster, the robot turns right.

Three common maneuvers:

  • Gradual left turn: right wheel at full, left wheel at half.
  • Spin left in place: right wheel forward, left wheel backward at the same speed.
  • Spin right in place: left wheel forward, right wheel backward at the same speed.
1
2
3
4
5
6
7
8
9
def spin_left(speed=32767):
    """Spin in place to the left."""
    set_speed(right_fwd, right_rev, speed)    # right wheel forward
    set_speed(left_fwd,  left_rev,  -speed)   # left wheel backward

def turn_gradual_left(speed=32767):
    """Gentle left curve — right faster, left slower."""
    set_speed(right_fwd, right_rev, speed)
    set_speed(left_fwd,  left_rev,  speed // 2)

Diagram: Differential Drive Turn Simulator

Run Differential Drive Turn Simulator Fullscreen

Interactive MicroSim showing robot turn behavior for different wheel speed ratios

Type: MicroSim sim-id: differential-drive-simulator
Library: p5.js
Status: Specified

Create a p5.js MicroSim with a 700 × 450 canvas. Show a top-down view of the robot (a rectangle with L and R labels for left and right wheels).

Two vertical sliders on the left panel: - "Left wheel speed" from -100% to +100% - "Right wheel speed" from -100% to +100%

The robot animates in the top-down view, turning and moving based on the differential of the two speeds. Use simple Euler integration to update position and heading each frame. Show the robot's path as a fading trail of dots.

Preset buttons below the sliders: "Forward", "Backward", "Spin Left", "Spin Right", "Gradual Left", "Gradual Right". Each sets the sliders to the correct values and starts the animation.

A "Reset position" button returns the robot to center.

Learning objective (Bloom's Taxonomy — Applying): students predict and verify robot motion from left/right speed ratios.

Responsive: redraw on window resize.


Servo Motors

A servo motor is a different kind of actuator from a DC motor. Instead of spinning continuously, a servo rotates to a specific angle — anywhere from 0° to 180° in a standard servo. Servos are used for steering, sensor sweeping, and gripper arms.

Servos are also controlled by PWM, but the duty cycle means something different than it does for DC motors. For a servo:

  • A pulse width of 1 ms (at 50 Hz) corresponds to approximately 0°.
  • A pulse width of 1.5 ms corresponds to approximately 90° (center).
  • A pulse width of 2 ms corresponds to approximately 180°.

Servo Angle Range

The servo angle range (0°–180°) maps to PWM pulse widths (1–2 ms). Since we specify duty cycle as a 16-bit number (0–65535) for a 50 Hz signal (period = 20 ms):

  • 1 ms pulse = 1/20 = 5% duty cycle = 0.05 * 65535 ≈ 3276
  • 2 ms pulse = 2/20 = 10% duty cycle = 0.10 * 65535 ≈ 6553

Before you calibrate anything, it helps to see how a servo reads its command. The MicroSim below draws the 20 ms PWM cycle as a timeline and shows the servo arm at the same time. Drag the angle slider and watch the HIGH pulse grow and shrink between 1 ms and 2 ms. The pulse width sets the angle. The cycle length stays the same.

Diagram: Servo Pulse Width Explorer

Run Servo Pulse Width Explorer Fullscreen

Interactive MicroSim linking servo angle, pulse width, and 16-bit duty value

Type: microsim sim-id: servo-pulse-width-explorer
Library: p5.js
Status: Specified
Reuse: learning-micropython / servo-pwm-explorer (https://github.com/dmccreary/learning-micropython/tree/main/docs/sims/servo-pwm-explorer). Keep the pulse-versus-angle idea. Change the labels to the robot's config.SERVO_PIN, add the 16-bit duty readout, and add the calibration sliders.

Learning objective: Explain (Bloom L2) — the student can explain how a servo angle from 0° to 180° maps to a pulse width from 1 ms to 2 ms and to a duty value from about 3276 to 6553 at 50 Hz.

Canvas layout: Width fills the page (up to 700 px). Height 480 px. The top 45% shows the servo arm view. The middle 30% shows the PWM timeline. The bottom 25% holds the controls and readouts.

Visual elements: - Servo view: a gray rounded-rectangle servo body with a blue half-circle scale from 0° (left) to 180° (right). Tick marks and labels sit every 45°. A thick orange arm rotates from the servo shaft to point at the current angle. - Timeline: two full 20 ms cycles drawn as a square wave. The HIGH pulse is orange, the LOW time is light gray. A double-arrow labels "Pulse: 1.50 ms". A second arrow labels "Period: 20 ms". A vertical dotted line marks the end of each period. - A faint ghost pulse at 1 ms and another at 2 ms show the two ends of the range. - Readout panel with three lines: "Angle: 90°", "Pulse: 1.50 ms", "duty_u16: 4914".

Interactive controls: - Angle slider, 0 to 180, step 1, default 90. Changing it moves the arm and redraws the pulse. - "Min duty" slider, 2500 to 4000, step 10, default 3276 (the 0° value). - "Max duty" slider, 5500 to 7500, step 10, default 6553 (the 180° value). - Button "Sweep" toggles an automatic 0° to 180° to 0° sweep in 5° steps every 20 ms, like the Servo Sweep Code below. - Button "Reset calibration" restores the default min and max duty.

Behavior: - duty = int(min_duty + (angle / 180) * (max_duty - min_duty)). This matches angle_to_duty(). - pulse_ms = duty / 65535 * 20. - Drawn pulse width on the timeline = pulse_ms / 20 of the period length. - If the calibration sliders cross (min_duty >= max_duty), show a red message "Min must be smaller than max" and freeze the arm. - While "Sweep" runs, the angle slider follows the sweep and is disabled.

Default state: Angle 90°, default calibration, sweep off. The readout shows "Pulse: 1.50 ms" and "duty_u16: 4914".

Assessment/Challenge: Set the angle to 45°. What pulse width and duty value do you see? (Answer: about 1.25 ms and duty 4095 with the default calibration.) Then raise "Max duty" to 7000. What is the new duty at 180°? (Answer: 7000.)

Responsive: redraw on window resize.

The servo on your robot reads the same kind of pulse. Your code sets servo.duty_u16(duty), and the servo turns the pulse width into an angle. If your real servo stops short of 180°, use the calibration sliders to find the duty values that fit your servo.

Servo PWM Calibration

Servo PWM calibration means finding the exact duty cycle values for 0° and 180° on your specific servo. Manufacturers allow some variation, so the actual 0° position might be at 3000 or 3500 duty cycle units, not exactly 3276. Calibrate by setting a value, observing the actual angle, and adjusting until it matches.

Linear Range Mapping

Linear range mapping is a formula to convert an angle (0–180) to a duty cycle value (min_duty to max_duty). Before the code, here is the math: we scale the input angle proportionally across the output duty cycle range.

1
2
3
def angle_to_duty(angle, min_duty=3276, max_duty=6553):
    """Convert servo angle (0-180) to 16-bit duty cycle value."""
    return int(min_duty + (angle / 180) * (max_duty - min_duty))

This same idea shows up again and again in robotics. You will use it to turn a distance into a bar height and a knob position into a speed. The MicroSim below lets you see the formula work with any input range and any output range.

Diagram: Range Mapping Explorer

Run Range Mapping Explorer Fullscreen

Interactive MicroSim showing how a linear map converts an input range into an output range

Type: microsim sim-id: range-mapping-explorer
Library: p5.js
Status: Specified
Reuse: None — new design

Learning objective: Apply (Bloom L3) — the student can pick input and output ranges and predict the mapped value with out_min + (x - in_min) / (in_max - in_min) * (out_max - out_min).

Canvas layout: Width fills the page (up to 700 px). Height 450 px. The top 55% shows two parallel number lines (input on top, output below) joined by a slanted connector line. The bottom 45% holds a preset menu, the sliders, and the formula readout.

Visual elements: - Input number line: horizontal bar with the in_min and in_max labels at the ends, and a blue marker for the current input x. - Output number line: horizontal bar with the out_min and out_max labels, and an orange marker for the mapped value. - A dashed line joins the two markers. Thin gray lines join in_min to out_min and in_max to out_max, so the student sees the two ranges lined up. - A live formula box that fills in the real numbers, for example "3276 + (90 - 0) / (180 - 0) * (6553 - 3276) = 4914". - If x is outside the input range, the orange marker turns red and shows the note "Outside range - clamp it!"

Interactive controls: - Dropdown "Preset" with four choices. "Servo angle to duty" sets input 0 to 180 and output 3276 to 6553. "ToF distance to bar height" sets input 0 to 200 cm and output 0 to 50 pixels. "Pot to speed" sets input 0 to 65535 and output 0 to 100 percent. "Custom" unlocks the four range boxes. - Four number boxes: in_min, in_max, out_min, out_max. They are editable only in "Custom" mode. - Input slider from in_min - 20% of the range up to in_max + 20% of the range, default at the middle of the range. - Checkbox "Clamp output", off by default. When on, the mapped value is limited to the output range. - Checkbox "Round to integer", on by default.

Behavior: - mapped = out_min + (x - in_min) / (in_max - in_min) * (out_max - out_min). - If in_max equals in_min, show "Input range cannot be zero" and stop the math. - Reversed output ranges (out_min larger than out_max) are allowed. The connector lines cross to show the flip. - Round to integer uses int(), which drops the decimal part, like the angle_to_duty() code above.

Default state: Preset "Servo angle to duty", input 90, "Round to integer" on, clamp off. The readout shows 4914.

Assessment/Challenge: Choose "ToF distance to bar height" and set the input to 150 cm. What bar height do you get? (Answer: 37 pixels.) Now set the input to 250 cm. What happens with clamp off and with clamp on? (Answer: 62 pixels with clamp off, which is taller than the 50-pixel bar area. 50 pixels with clamp on.)

Responsive: redraw on window resize.

This formula turns one range of numbers into another. Your servo code uses it to turn an angle into a duty value. The same idea can turn a knob position into a speed. The clamp option shows why robot code often limits a result with min() and max().

Servo Sweep Code

A servo sweep moves the servo from 0° to 180° and back in a smooth loop. The range() function generates the angle sequence:

 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
from machine import PWM, Pin
from time import sleep
import config

servo = PWM(Pin(config.SERVO_PIN), freq=50)

def set_angle(angle):
    duty = angle_to_duty(angle)
    servo.duty_u16(duty)

# Sweep from 0 to 180 and back
try:
    while True:
        for angle in range(0, 181, 5):     # 0° to 180° in 5° steps
            set_angle(angle)
            sleep(0.02)
        for angle in range(180, -1, -5):   # 180° back to 0°
            set_angle(angle)
            sleep(0.02)

except KeyboardInterrupt:
    pass

finally:
    servo.deinit()

The sleep(0.02) gives the servo time to physically move to each position before the next command arrives.


Piezo Buzzer — Sound Feedback

A piezo buzzer is a simple device that vibrates when AC voltage is applied. PWM produces AC-like rapid switching, which makes the piezo element vibrate at the PWM frequency. That vibration moves air and produces sound.

The pitch of the tone is determined by the PWM frequency: higher frequency → higher pitch. Standard musical notes correspond to specific frequencies (e.g., middle C = 262 Hz, A4 = 440 Hz).

Tone Frequency Control

To play a tone, set the buzzer pin's PWM frequency to the desired pitch, then set the duty cycle to 50% (equal on/off time produces the loudest tone):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from machine import PWM, Pin
from time import sleep
import config

buzzer = PWM(Pin(config.SPEAKER_PIN))

def play_tone(frequency, duration):
    """Play a tone at the given frequency (Hz) for duration seconds."""
    buzzer.freq(frequency)
    buzzer.duty_u16(32767)   # 50% duty cycle for maximum volume
    sleep(duration)
    buzzer.duty_u16(0)       # silence between notes

# Play a simple startup melody
play_tone(440, 0.2)    # A4
play_tone(523, 0.2)    # C5
play_tone(659, 0.3)    # E5

Numbers like 440 and 659 are hard to picture. The MicroSim below shows a frequency as a wave and as a note on a piano keyboard. Click a key or drag the frequency slider, then compare how the wave changes.

Diagram: Piezo Tone Frequency Explorer

Run Piezo Tone Frequency Explorer Fullscreen

Interactive MicroSim that plays a PWM tone and shows its frequency, wave, and piano note

Type: microsim sim-id: piezo-tone-frequency-explorer
Library: p5.js (with the p5.sound oscillator for audio)
Status: Specified
Reuse: learning-micropython / piano-tone-generator (https://github.com/dmccreary/learning-micropython/tree/main/docs/sims/piano-tone-generator). Keep the keyboard and the oscillator. Add the frequency slider, the wave view, the "Duty" control, and the MicroPython code box.

Learning objective: Relate (Bloom L2) — the student can relate the PWM frequency passed to buzzer.freq() to the pitch they hear, and can explain why 50% duty is the loudest setting.

Canvas layout: Width fills the page (up to 700 px). Height 470 px. The top 30% is a one-octave piano keyboard (C4 to C5, 13 keys). The middle 35% is the wave view. The bottom 35% holds controls and a code box.

Visual elements: - Keyboard: white and black keys. The active key turns orange. Each key shows its note name (C4, D4, and so on). - Wave view: a square wave with 3 to 12 cycles visible, scaled so higher frequencies show more cycles. The HIGH part is orange and the LOW part is light gray. A label reads "Frequency: 440 Hz" and "Period: 2.27 ms". - A small speaker icon that pulses in time with the sound. It is dimmer at low duty cycles. - Code box (monospace, light gray) showing buzzer.freq(440) and buzzer.duty_u16(32767), updated live.

Interactive controls: - Frequency slider, 100 to 2000 Hz, step 1, default 440. - Duty slider, 0% to 100%, step 5, default 50%. It shows the matching 16-bit value, for example "50% = 32767". - Piano keys: clicking a key sets the frequency to that note's frequency (C4 262, D4 294, E4 330, F4 349, G4 392, A4 440, B4 494, C5 523, with black keys at their standard frequencies). - Button "Play / Stop" toggles the sound. The sound starts only after the first click, because browsers block audio until then. - Button "Startup melody" plays 440 Hz for 0.2 s, 523 Hz for 0.2 s, then 659 Hz for 0.3 s, matching the code above.

Behavior: - Period in ms = 1000 / frequency. - The nearest note name shows below the frequency, for example "Nearest note: A4". - The oscillator plays a square wave at the chosen frequency. Its loudness is scaled by how close the duty is to 50%: volume = 1 - abs(duty - 50) / 50. At 0% and 100% duty the volume is 0 (silent), because the pin never changes state. - The wave view uses the duty setting for the HIGH width of each cycle. - If audio is not available, the sim still animates and shows the message "Sound is off in this browser".

Default state: Frequency 440 Hz (A4 highlighted), duty 50%, sound stopped.

Assessment/Challenge: Find the frequency that plays E5 in the startup melody. (Answer: 659 Hz.) Then set the duty to 0%. What do you hear, and why? (Answer: Nothing. A pin that stays LOW never vibrates the piezo element.)

Responsive: redraw on window resize.

On the robot, play_tone() sets the buzzer pin's frequency and duty in the same way. Try the startup melody in the sim first. Then match its three notes in your own code and hear the same tune from your robot.

Sound Feedback

Sound feedback is a useful user experience pattern — play a tone when the robot starts up, when it detects an obstacle, or when a button is pressed. It communicates state without requiring the student to watch the OLED display.

A short high-pitched beep for detection, a low-pitched buzz for a warning, and a rising melody for success are easy to implement and significantly improve the robot's expressiveness.

Sound is output too

Sparky thinking Sensors are inputs. Motors and displays are outputs. The buzzer is also an output — but an audio one. In computational thinking terms, the buzzer is an actuator just like a motor. It converts an electrical signal (PWM) into a physical effect (sound waves). Keep that input/output mental model in mind as you add features.


GPIO Interrupts and Button Debouncing

So far, you've checked sensors inside a while True loop. This is called polling — you ask "has anything changed?" on every iteration. Polling works fine for slow sensors, but for fast events like button presses, there's a better approach: interrupts.

GPIO Interrupt Setup

A GPIO interrupt tells the microcontroller to automatically call a function the moment a pin changes state — without waiting for the main loop to get to it. You attach (register) the interrupt by calling irq() on a Pin object.

Before the code, here is what the parameters mean: trigger specifies which signal edge triggers the interrupt, and handler specifies the function to call.

1
2
3
4
5
6
7
8
from machine import Pin

button = Pin(20, Pin.IN, Pin.PULL_UP)

def button_pressed(pin):
    print("Button pressed!")

button.irq(trigger=Pin.IRQ_FALLING, handler=button_pressed)

IRQ Falling Edge

The IRQ falling edge trigger fires when the pin changes from HIGH to LOW. With an active-low button (pressed = LOW, because of the pull-up resistor), this is exactly when the button is pressed.

Why FALLING? The button pin is connected to 3.3 V through a pull-up resistor — an internal resistor that holds the pin HIGH when nothing is pressing it. When you press the button, it connects the pin to ground (0 V), pulling it LOW. That transition from HIGH to LOW is the falling edge.

Pin.IRQ_RISING fires when the pin goes from LOW to HIGH — i.e., when the button is released.

Button Debouncing

Physical buttons don't switch cleanly. When you press a button, the metal contacts bounce — making and breaking contact 5–50 times in just a few milliseconds. Without handling this, your interrupt fires 10–20 times per press instead of once.

Debouncing is the technique of ignoring the extra bounces. The simplest approach for robot code is to record the time of the last interrupt and ignore any new interrupt that arrives within 50–200 ms:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from machine import Pin
from time import ticks_ms, ticks_diff

button = Pin(20, Pin.IN, Pin.PULL_UP)
last_press_time = 0

def button_pressed(pin):
    global last_press_time
    now = ticks_ms()
    if ticks_diff(now, last_press_time) > 150:    # 150 ms debounce
        last_press_time = now
        print("Button press registered!")

button.irq(trigger=Pin.IRQ_FALLING, handler=button_pressed)

The ticks_diff() function handles timer rollover correctly, just as in Chapter 4.

The bounce happens in a few thousandths of a second, so you cannot see it. The MicroSim below slows time down so you can watch it. Press the virtual button, look at the noisy signal, and see how the debounce window changes the number of presses your code counts.

Diagram: Button Bounce Timeline

Run Button Bounce Timeline Fullscreen

Interactive MicroSim showing contact bounce, falling-edge interrupts, and the debounce window

Type: microsim sim-id: button-bounce-timeline
Library: p5.js
Status: Specified
Reuse: None — new design

Learning objective: Analyze (Bloom L4) — the student can explain why one press causes many falling edges, and can choose a debounce time that counts each press once without missing real presses.

Canvas layout: Width fills the page (up to 700 px). Height 500 px. Three stacked strips share one time axis (0 to 200 ms) in the top 65%. The bottom 35% holds the controls and counters.

Visual elements: - Strip 1, "Pin 20 signal": a step waveform. It rests HIGH at 3.3 V (pull-up) and drops to LOW 0 V when the button closes. During the bounce it flips between HIGH and LOW several times before settling LOW. - Strip 2, "Falling edges (IRQ fires)": a red arrow at each HIGH-to-LOW transition, numbered 1, 2, 3, and so on. - Strip 3, "Presses counted": a green check mark at each edge that the handler accepts. A gray "ignored" mark shows at each edge inside the debounce window. - A shaded blue band starts at the first accepted edge and runs for the debounce time, labeled "Debounce window: 150 ms". - A large button graphic at the bottom left. It shows pressed (dark) or released (light). - Two counters: "Falling edges seen: N" and "Presses counted: N".

Interactive controls: - Button "Press" simulates one press. It builds a new bounce pattern and plays it across the timeline. - Button "Press twice quickly" simulates two real presses 100 ms apart. - Slider "Bounce length", 0 to 30 ms, step 1, default 12. It is how long the contacts bounce. - Slider "Debounce window", 0 to 300 ms, step 10, default 150. It matches the > 150 test in the code. - Checkbox "Show code" reveals the button_pressed() handler with the line if ticks_diff(now, last_press_time) > 150: updated to the slider value. - Button "Reset counters".

Behavior: - On "Press", generate 3 to 12 random bounces inside the bounce length. Each bounce is a short LOW pulse of 0.2 to 2 ms, alternating with HIGH gaps of 0.2 to 2 ms, followed by a final settle to LOW. With a bounce length of 0, there is one clean edge. - Every HIGH-to-LOW transition is an IRQ event. - The handler follows the code: accept an event only if the time since the last accepted event is greater than the debounce window. Otherwise, ignore it. - With a debounce window of 0, every edge is counted, so one press can count 3 to 12 times. - "Press twice quickly" uses two presses 100 ms apart. With a 150 ms window the second press is ignored. Show the note "A real press was missed!" when a real press lands inside the window. - The timeline runs at slow motion (about 1 s of screen time for 200 ms) so students can follow it.

Default state: Bounce length 12 ms, debounce window 150 ms, counters at 0, waveform resting HIGH.

Assessment/Challenge: Set the debounce window to 0 and press once. How many presses were counted? (Answer: usually more than one, equal to the number of falling edges.) Now use "Press twice quickly". What is the largest window that still counts both presses? (Answer: any window under 100 ms, for example 50 ms, when the bounce is short.)

Responsive: redraw on window resize.

Your robot's button on pin 20 behaves like this virtual one. The 150 ms window in the button_pressed() handler is a trade-off. A short window may count one press twice. A long window may miss a fast second press.

Keep interrupt handlers short

Sparky warning Interrupt handler functions (like button_pressed) run in a special context. Keep them short — set a flag, record a timestamp, and exit. Do NOT call sleep(), print(), or I2C functions inside an interrupt handler. These can cause crashes. Instead, set a boolean flag like button_was_pressed = True in the handler, then check that flag in your main loop and take action there.


Putting It All Together

Here is a complete robot program that uses PWM for motor control, plays a startup tone, and handles a button interrupt. It demonstrates every concept from this chapter working together.

 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
from machine import PWM, Pin
from time import sleep, ticks_ms, ticks_diff
import config

# Set up motors
right_fwd = PWM(Pin(config.RIGHT_FORWARD_PIN), freq=50)
right_rev = PWM(Pin(config.RIGHT_REVERSE_PIN), freq=50)
left_fwd  = PWM(Pin(config.LEFT_FORWARD_PIN),  freq=50)
left_rev  = PWM(Pin(config.LEFT_REVERSE_PIN),  freq=50)
buzzer    = PWM(Pin(config.SPEAKER_PIN))

button_flag = False
last_press  = 0

def on_button(pin):
    global button_flag, last_press
    if ticks_diff(ticks_ms(), last_press) > 150:
        last_press = ticks_ms()
        button_flag = True

button = Pin(20, Pin.IN, Pin.PULL_UP)
button.irq(trigger=Pin.IRQ_FALLING, handler=on_button)

def beep(freq=440, dur=0.1):
    buzzer.freq(freq); buzzer.duty_u16(32767)
    sleep(dur); buzzer.duty_u16(0)

beep(659, 0.2)   # startup tone

try:
    while True:
        if button_flag:
            button_flag = False
            beep(880, 0.1)
            print("Button pressed!")
        sleep(0.01)

except KeyboardInterrupt:
    pass

finally:
    right_fwd.duty_u16(0); right_rev.duty_u16(0)
    left_fwd.duty_u16(0);  left_rev.duty_u16(0)
    buzzer.duty_u16(0)
    print("Motors and buzzer off.")

Key Takeaways

  • PWM switches a pin between HIGH and LOW rapidly — the duty cycle controls average voltage
  • Duty cycle is expressed as a 16-bit integer (0–65535) in MicroPython
  • Motor speed is controlled by the duty cycle on the forward or reverse pin
  • Differential drive turns the robot by running left and right wheels at different speeds
  • Servo motors rotate to a specific angle, controlled by PWM pulse width (1–2 ms at 50 Hz)
  • Linear mapping converts an angle (0–180) to a duty cycle value
  • Piezo buzzer tone pitch is controlled by PWM frequency
  • GPIO interrupts call a handler function the instant a pin changes state — faster than polling
  • Debouncing ignores extra button-bounce triggers within a short time window

Your robot is alive — and it's rolling!

Sparky celebrating Double thumbs-up! You wrote real code that spins my wheels, turns me, sweeps a servo, and plays tones. That's physical computing — software controlling the physical world. Next chapter, we add eyes: sensors that let me measure the world around me. The robot is about to get smart!