Skip to content

GPIO & Sensors

Time: 135 min

Learning Objectives

By the end of this lab you will be able to:

  • Configure GPIO pins as digital outputs and inputs
  • Explain why floating inputs produce undefined values and how pull resistors fix it
  • Describe how optocoupler (infrared reflectance) sensors work
  • Read line sensors and interpret their raw digital values
  • Predict sensor behavior on different surfaces and verify predictions

You will move from controlling outputs (LEDs, buzzer) to reading the world through sensors. Along the way, you'll encounter a classic embedded mistake — the floating input — and fix it.

Lab Setup

Connect your Pico via USB, then from the picobot/ directory:

mpremote run clean_pico.py          # optional: wipe old files
mpremote cp -r lib :               # upload libraries

Verify in REPL: from picobot import Robot; Robot() → "Robot ready!" First time? See the full setup guide.


Introduction

In Robot Unboxing, you made the robot do things: LEDs flashed, buzzers beeped, motors spun. But the robot had no idea what was happening around it. It was blind.

This lab introduces inputs — reading the physical world through GPIO pins and sensors. But first, you'll discover that reading a pin is not as simple as it sounds.


Why This Lab Matters

Every control system needs measurement. Before you can follow a line, avoid an obstacle, or turn precisely, you need to read sensors. This lab builds the foundation: GPIO input, pull-up resistors, and optocoupler sensors. These concepts appear in every embedded system you will ever build.


Part 1: GPIO Output — Beyond LEDs (10 min)

What is GPIO?

GPIO = General Purpose Input/Output. These are the pins on the Pico that you configure as either outputs (send signals) or inputs (receive signals).

In Robot Unboxing, you used robot.set_leds() — but that hides the GPIO details. Let's work directly with pins.

from machine import Pin
import time

# Configure LED symbol as an output (on-board LED on some Pico boards). The Pico 2 and Pico2 W differ so use just LED symbol instead of direct GPIO id e.g. 25
led = Pin("LED", Pin.OUT)

led.on()       # Pin goes HIGH (3.3V)
time.sleep(1)
led.off()      # Pin goes LOW (0V)
time.sleep(1)
led.toggle()   # Flip state

⚡Hands-on tasks

Make the on-board LED blink at 2 Hz (on for 250ms, off for 250ms) using only Pin and time.sleep():

from machine import Pin
import time

led = Pin("LED", Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.25)
What Does Pin.OUT Mean?

When you write Pin("LED", Pin.OUT), you're telling the microcontroller:

  • "LED": Use the built-in LED pin (on Pico 2 this is not a simple GPIO number)
  • Pin.OUT: Configure as output — the Pico drives this pin HIGH (3.3V) or LOW (0V)

The opposite is Pin.IN — the Pico reads the voltage on the pin instead of setting it.

Checkpoint — Direct GPIO Control

You can blink an LED using Pin directly, without the picobot library. This is exactly what library functions do internally — just wrapped in a nicer interface.


Part 2: What Happens When You Read a Pin? (20 min)

Outputs send signals to the world. Inputs read signals from the world. Let's try reading a pin.

TRY: Read a Pin

from machine import Pin
import time

# Configure GP9 as input — no pull resistor
sensor = Pin(9, Pin.IN)

while True:
    print(f"GP9: {sensor.value()}")
    time.sleep(0.2)

Run this with nothing connected to GP9. What do you see?

What did you expect? What actually happened?

You might expect random values — that is what textbooks say about floating pins. On the Pico 2 (RP2350), you will likely see a stuck 0 or 1 instead. This is due to an RP2350 errata: internal GPIO leakage currents pull the pin weakly toward its last driven state, so a floating pin latches to a consistent value rather than bouncing randomly.

This is actually worse than random. A random reading is obviously wrong. A stuck value looks correct — you might think the sensor is connected and working when it is not. Silent failures are harder to debug than noisy ones.

Why Does It Read a Stuck Value?

An unconnected input pin is called floating — it is not connected to any defined voltage. Textbooks say floating pins read random noise. On the Pico 2 (RP2350), you see a stuck 0 or 1 instead — this is due to a known silicon errata where GPIO leakage current (~120μA) pulls the pin to ~2.2V, and the digital input latches to a consistent value.

The takeaway: don't trust a pin reading unless something is actually driving it. A stuck value looks correct but means nothing.

How real circuits fix this

In practice, sensor boards include pull-up or pull-down resistors in their circuit to give every pin a defined default voltage. Your robot's line sensor board already has this — so when you connect the actual sensors in Part 3, the readings will be reliable without any extra configuration from your code.

If you ever need to fix a floating pin in software, use Pin.PULL_UP:

sensor = Pin(9, Pin.IN, Pin.PULL_UP)  # Default HIGH when nothing connected
On the Pico 2, PULL_DOWN is too weak to overcome the leakage errata — only PULL_UP or an external resistor (≤8.2kΩ) works reliably.

Checkpoint — Floating Pins

You observed what happens when a pin has no defined voltage. On the Pico 2, this looks like a stuck value rather than random noise — which is deceptive. The fix in real circuits is hardware pull resistors, which your sensor board already provides.


Part 3: Meet the Line Sensors (25 min)

Background: The 5-Step Sensor Integration Process

Every sensor you'll ever integrate — IR reflectance, ultrasonic, IMU, temperature — follows the same five steps:

Step Question
1. Understand What does it measure? What are its limits?
2. Communicate Analog voltage? Digital protocol (I2C, SPI)?
3. Connect Wire it. Check voltage levels and current limits.
4. Implement Write code to read raw values, then interpret them.
5. Verify Compare to known references. Measure noise and repeatability.

You'll apply these steps now with the optocouplers, and again with every new sensor throughout the course.

→ Deep dive: Optocoupler Sensors | → ADC Basics

How Optocouplers Work

Your robot has 4 line sensors on its underside. Each sensor is an optocoupler — a pair of components:

  • Infrared (IR) LED — constantly shines invisible light downward
  • Phototransistor — detects how much light bounces back

The principle:

Surface What Happens Sensor Output
White/light IR light reflects back → phototransistor conducts → pulls pin LOW 0
Black/dark IR light absorbed → phototransistor stays off → pull-up keeps pin HIGH 1

Line Sensor: IR Reflection Principle

Wait — why is "detected" a 0?

The phototransistor has a pull-up resistor (just like Part 2!). When light reflects back, the transistor conducts and pulls the pin to ground → LOW (0). When no light returns, the pull-up keeps it HIGH → 1. This "active-low" pattern is common in electronics. Always check the hardware before assuming what 0 and 1 mean.

Try This!

Your phone camera can see infrared light! Point your phone camera at the robot's belly while it's powered on — you'll see the IR LEDs glowing purple. This is a quick way to verify the sensors are working.

Sensor Layout

Sensor GPIO Position Label
X1 GP2 Far left Left outer
X2 GP3 Left-center Left inner
X3 GP4 Right-center Right inner
X4 GP5 Far right Right outer

Each sensor has a small blue LED that lights up when it detects a dark surface. You can test sensors visually without writing any code.

✅ Task 3 — Read One Sensor

Read a single line sensor directly using GPIO:

from machine import Pin
import time

# Sensor X1 is on GP2
sensor_x1 = Pin(2, Pin.IN)

print("Hold robot over different surfaces")
print("1 = reflective (white), 0 = non-reflective (black)")
print()

while True:
    val = sensor_x1.value()
    indicator = "██ White" if val else "░░ Black"
    print(f"X1 (GP2): {val}  {indicator}")
    time.sleep(0.2)
Why don't we need PULL_UP here?

The line sensor board has external pull-up resistors built into its circuit. The phototransistor and resistor form a complete voltage divider, so the GPIO pin always sees a defined voltage. Not all sensors are this well-designed — always check!

✅ Task 4 — Read All Four Sensors

from machine import Pin
import time

sensors = [
    Pin(2, Pin.IN),  # X1 - far left
    Pin(3, Pin.IN),  # X2 - left center
    Pin(4, Pin.IN),  # X3 - right center
    Pin(5, Pin.IN),  # X4 - far right
]

print("Slide robot over black tape")
print()

while True:
    values = [s.value() for s in sensors]

    pattern = ""
    for v in values:
        pattern += "██" if v else "░░"

    print(f"X1-X4: {values}  |{pattern}|")
    time.sleep(0.1)

Move the robot over a strip of black tape and record what you see:

Robot Position Expected Values Your Observation
All on white [0, 0, 0, 0]
Tape under X1 only [1, 0, 0, 0]
Tape centered (X2, X3) [0, 1, 1, 0]
Tape under X4 only [0, 0, 0, 1]
All on black [1, 1, 1, 1]
Checkpoint — All Sensors Reading

You can read all 4 line sensors and see values change as you move the robot over tape. The pattern display clearly shows which sensors detect the tape.

Stuck?
  • All sensors always 0: Surface may be too reflective (glossy). Try matte white paper with matte black electrical tape.
  • All sensors always 1: Check sensor height — should be 2–3mm above surface. If too far, no reflection reaches the phototransistor.
  • One sensor never changes: It may be damaged or blocked. Check for debris on the sensor window.

Part 4: From Raw Pins to Library (10 min)

Reading raw GPIO pins works, but the picobot library provides a cleaner interface:

from picobot import Robot
import time

robot = Robot()

while True:
    values = robot.sensors.line.read_raw()
    pattern = robot.sensors.line.get_pattern()

    print(f"Sensors: {values}  |{pattern}|")
    time.sleep(0.1)

✅ Task 5 — Compare Raw GPIO vs Library

Method Values on white Values on black tape
Direct GPIO (Pin.value())
picobot (read_raw())

Do they give the same results? They should — the comparator on the sensor board already inverts the signal so both Pin.value() and read_raw() return 1 = white, 0 = black. The library is a wrapper around the same GPIO reads you did manually.

From Raw Values to Useful Data

Right now you're reading individual sensor values (0 or 1). In Line Following, you'll combine these into a single error value that tells the robot "the line is slightly left" or "the line is far right." But first, you need to understand what the raw values mean — that's what this lab is about.


Part 5: Can You Predict What the Sensor Will Read? (25 min)

Good engineers don't just read data — they predict what data should look like, then check if reality matches. This is how you catch bugs, faulty sensors, and bad assumptions.

✅ Task 6 — Surface Prediction Challenge

For each surface below, predict the sensor output BEFORE testing, then verify:

Surface Your Prediction Actual Value Correct? Why?
White paper
Black electrical tape
Your hand (close)
Wooden desk
Metal surface
Glossy black plastic
Robot lifted high (no surface)
Colored paper (red, blue)
Which predictions were wrong?

Glossy black often surprises people. Even though it's black, a shiny surface reflects IR light specularly (like a mirror). The sensor may read 0 (white) even though the surface looks black to your eyes!

This is an important lesson: sensors don't see the world the way you do. They respond to physical properties (IR reflectance), not human labels ("black" vs "white").

✅ Task 7 — LED Feedback

Make the robot's LEDs mirror the sensor readings:

from picobot import Robot
import time

robot = Robot()

SENSOR_LEDS = [1, 3, 4, 6]  # Map sensors to LED positions

while True:
    values = robot.sensors.line.read_raw()

    for i, val in enumerate(values):
        led_idx = SENSOR_LEDS[i]
        if val:  # White (reflective)
            robot.set_led(led_idx, (0, 50, 0))   # Dim green
        else:    # Black (non-reflective)
            robot.set_led(led_idx, (255, 0, 0))  # Red

    time.sleep(0.05)

Slide the robot over tape and watch the LEDs mirror the sensors in real time.

✅ Task 8 — Line Crossing Counter

Count how many times the robot crosses a black line. This introduces edge detection — responding to changes rather than states:

from picobot import Robot
import time

robot = Robot()

count = 0
was_on_line = False

print("Slide robot across black lines — press Ctrl+C to stop")

try:
    while True:
        values = robot.sensors.line.read_raw()
        on_line = any(v == 1 for v in values)

        if on_line and not was_on_line:  # Transition: off → on
            count += 1
            print(f"Line #{count} detected!")
            robot.beep(440, 50)

        was_on_line = on_line
        time.sleep(0.02)

except KeyboardInterrupt:
    print(f"\nTotal lines crossed: {count}")

The was_on_line flag tracks the previous state so we only count the transition from off-line to on-line. Without it, every single reading while over the line would count as a separate detection. This edge-detection pattern appears constantly in embedded systems — buttons, limit switches, encoders all need it.

Checkpoint — Sensor-Driven Behavior

Your robot responds to what it sees: LEDs mirror sensors, a counter tracks line crossings. You understand the difference between reading a state and detecting a transition.


What You Discovered

The Bigger Picture

Physical World → Sensor → GPIO Pin → Digital Value → Your Code
(black tape)     (IR)     (GP2)       (0)            (if val == 0)

This chain — from physics to code — is the essence of embedded systems. You also learned that the chain can lie: glossy black is invisible to IR, floating pins produce garbage, and "active-low" logic inverts expectations.

Key Concepts

Concept What You Learned
GPIO Output Pin(n, Pin.OUT) — Pico drives voltage HIGH or LOW
GPIO Input Pin(n, Pin.IN) — Pico reads external voltage
Floating pin Without pull resistor, input reads garbage
Pull-up resistor Provides stable default HIGH; goes LOW when grounded
Active-low logic Many sensors output 0 when "active" (detecting something)
Optocoupler IR LED + phototransistor detects reflective vs non-reflective
Edge detection Respond to changes, not states

You used print() extensively today to see what the sensors read. This is the most important debugging tool in embedded systems — professional engineers call it "printf debugging" and use it at every level, from student projects to spacecraft.


What's Next?

In Reading the Analog World, you'll move beyond digital 0s and 1s to read continuous values — temperature, light, and battery voltage — using the ADC.


Challenges (Optional)

These challenges build on the code from Task 4 (read_raw() loop). Try them in order — each adds a new idea.

Useful references: GPIO Basics · Optocoupler Sensors · PicoCar Pinout · Key Code Reference

✅ Challenge 1 — Surface Classifier

Collect your robot and a selection of surfaces. Test each one and fill in the table:

Surface Expected Actual read_raw() Stable?
White paper [1,1,1,1]
Black electrical tape [0,0,0,0]
Wooden desk
Your hand
Phone screen (off)
Phone screen (on, white)
Glossy black folder
Gray paper / cardboard

Now write code that classifies a surface based on how many sensors read 1:

Hints
  • Use sum(values) to count how many sensors see white (return 1)
  • Classify: 4 = "WHITE", 0 = "BLACK", anything else = "EDGE/MIXED"
  • Print the raw values alongside your classification so you can verify
Think about it

Does the glossy black folder classify as "BLACK"? Why or why not? What does this tell you about what the sensor actually measures?

✅ Challenge 2 — Line Width Estimator

Use different tape widths (or stack multiple strips side by side) and slide the robot across them slowly:

Tape Setup Sensors Triggered Estimated Width
1 strip (~19mm) Usually 1–2 sensors Narrow
2 strips side by side Usually 2–3 sensors Medium
3+ strips 3–4 sensors Wide
Hints
  • sum(values) tells you how many sensors see black simultaneously
  • Map the count to a width category: 0 = no line, 1 = narrow, 2 = medium, 3–4 = wide
  • Print both raw values and your category so you can compare with the table above
Think about it

The sensor spacing matters — can you figure out the approximate distance between sensors by testing with known tape widths? Check the robot's PCB or datasheet.

✅ Challenge 3 — Reaction Time Game

The robot flashes a random LED, and you must slide it over a black line as fast as possible. It measures your reaction time. See Task 8 for the edge detection pattern and Addressable LEDs for LED control.

Hints
  • Use random.uniform(1, 4) for a random delay before the signal
  • robot.set_leds((0, 80, 0)) to flash green as the "GO" signal
  • Record the start time with time.ticks_ms(), then poll the line sensors until any reads 1
  • time.ticks_diff(end, start) gives you elapsed milliseconds

Extension: Can you get under 500ms? Under 300ms? Add a loop that runs 5 rounds and prints your average.

✅ Challenge 4 — Barcode Reader

Create a "barcode" with strips of tape on white paper (e.g., thin-gap-thick-gap-thin). Slide the robot across it and decode the pattern. This builds on the edge detection from Task 8.

Hints
  • Use the edge detection pattern from Task 6 (was_on_line flag) to detect when you enter/leave each bar
  • Record time.ticks_ms() when entering a bar, compute width when leaving
  • Store widths in a list — at the end, classify each bar as "thick" or "thin" relative to the average width
  • Use try/except KeyboardInterrupt so you can stop the scan with Ctrl+C
Think about it

The "width" in milliseconds depends on how fast you slide the robot. How could you make the reading speed-independent? (Hint: ratios between bars matter, not absolute widths.)

✅ Challenge 5 — Light Sensor (Analog Preview)

Your robot has two photoresistors (light sensors) that output an analog voltage — not just 0 or 1, but a value from 0 to 65535. This is your first look at the ADC (Analog-to-Digital Converter). You'll explore this in depth in Reading the Analog World. See also: Light Sensor Reference · ADC Basics.

  • Light Sensor 2 → GP26 (ADC0) — always available
  • Light Sensor 1 → GP27 (ADC1) — only available when the J7 jumper is set to "Light" (not "Mic")
Check the J7 jumper

If J7 is set to "Mic" (default), GP27 reads the microphone instead of the light sensor. For this challenge, you only need GP26 (Light Sensor 2) which works regardless of the jumper.

from machine import Pin, ADC
import time

light = ADC(Pin(26))  # Light Sensor 2 (GP26)

while True:
    raw = light.read_u16()  # 0–65535
    print(f"Light: {raw:5d}")
    time.sleep(0.2)

Try these and record what you see:

Condition Expected Your read_u16() value
Normal room light Mid-range
Cover sensor with finger High (~60000+)
Phone flashlight on sensor Low (~5000–10000)
Under desk / shadow
Wait — why does brighter = lower value?

The photoresistor's resistance drops in bright light. In the voltage divider circuit, lower resistance means lower voltage at the ADC pin. So bright = low number, dark = high number. This is the opposite of what most people expect — always check the hardware!

Extension: Convert the raw value to a percentage (0–100%):

Hints
  • First calibrate: cover the sensor and record the "DARK" value, shine a light and record the "BRIGHT" value
  • Remember: brighter = lower raw value, so the formula is (DARK - raw) / (DARK - BRIGHT) * 100
  • Clamp the result to 0–100 with max(0, min(100, percent))
  • Bonus: print a text-based bar using "█" * int(percent // 5)
Think about it

The line sensors (digital) tell you "black or white." The light sensor (analog) tells you "how bright." When would you want digital? When would you want analog? Could you use the light sensors to detect whether the robot is in a dark tunnel vs open track?


Recap

GPIO pins can be outputs (drive voltage) or inputs (read voltage). Unconnected inputs float and produce undefined values (on Pico 2, a stuck 0 or 1 due to leakage current) — pull resistors fix this. Optocoupler sensors use reflected IR light to detect surfaces, but they respond to IR reflectance, not human color perception. Understanding raw sensor behavior is essential before building control systems.


Key Code Reference

from machine import Pin

# Output
led = Pin("LED", Pin.OUT)
led.on()          # HIGH (3.3V)
led.off()         # LOW (0V)
led.toggle()      # Flip

# Input (always use a pull resistor!)
sensor = Pin(2, Pin.IN, Pin.PULL_UP)
value = sensor.value()  # 0 or 1

# picobot sensors
from picobot import Robot
robot = Robot()
values = robot.sensors.line.read_raw()     # [0, 1, 1, 0]
pattern = robot.sensors.line.get_pattern() # "░██░"

← Robot Unboxing | Labs Overview | Next: Analog Sensors →