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:
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
✅ Task 1 — Blink Without the Library
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:
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.
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 |

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 |
print() Debugging
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 (return1) - 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 reads1 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_lineflag) 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 KeyboardInterruptso 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() # "░██░"