Reading the Analog World
Time: 60 min
Learning Objectives
By the end of this lab you will be able to:
- Read analog values using the RP2350's built-in ADC
- Convert raw ADC readings to physical units (temperature, voltage, light level)
- Explain how voltage dividers allow reading voltages beyond the ADC range
- Calibrate a sensor and map raw readings to meaningful percentages
- Monitor battery voltage and estimate state of charge
You will move from the digital world (0s and 1s) to the analog world — continuous values that represent temperature, light intensity, and voltage.
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 GPIO & Sensors, you read digital inputs — pins that are either HIGH or LOW. The line sensors told you "black" or "white" with nothing in between.
But the real world has shades of gray. Temperature isn't "hot" or "cold" — it's 23.7°C. Light isn't "bright" or "dark" — it's somewhere on a continuous scale. Battery voltage slowly drops from 4.2V to 3.0V.
To read these continuous values, you need an Analog-to-Digital Converter (ADC) — hardware that converts a voltage into a number your code can use.
Why This Lab Matters
Every embedded system needs to measure the physical world. Temperature monitoring, battery management, light sensing — these are the bread and butter of embedded engineering. The ADC is the bridge between continuous physics and discrete computation. Understanding how it works, its limitations, and how to calibrate sensors is a skill you'll use in every project.
Part 1: Your First ADC Reading — Core Temperature (15 min)
The RP2350 has a built-in temperature sensor connected to ADC channel 4. No wiring needed — let's start there.
⚡Hands-on tasks
✅ Task 1 — Read Raw ADC Value
from machine import ADC
# Internal temperature sensor — no external pin needed
temp_adc = ADC(ADC.CORE_TEMP)
# Read raw 16-bit value
raw = temp_adc.read_u16()
print(f"Raw ADC value: {raw}")
Run this a few times. You should see a number somewhere around 17000–22000 (varies with temperature).
Why 16-bit When the ADC Is 12-bit?
The RP2350 has a 12-bit ADC (values 0–4095), but MicroPython's read_u16() returns a 16-bit value (0–65535). It left-shifts the 12-bit result by 4 (multiplies by 16) for cross-platform consistency — the same code works on ESP32 (12-bit), STM32 (12/16-bit), and other microcontrollers without changing the conversion math.
✅ Task 2 — Convert to Temperature
The RP2350 datasheet gives this conversion formula:
Where \(V_{adc}\) is the voltage at the ADC input:
from machine import ADC
import time
temp_adc = ADC(ADC.CORE_TEMP)
while True:
raw = temp_adc.read_u16()
voltage = (raw / 65535) * 3.3
temperature = 27 - (voltage - 0.706) / 0.001721
print(f"Raw: {raw} Voltage: {voltage:.3f}V Temp: {temperature:.1f}°C")
time.sleep(1)
Try this: Blow warm air on the Pico chip and watch the temperature rise. It responds slowly because you're measuring the chip's temperature, not the air.
Measurement Accuracy
The internal temperature sensor is useful for monitoring chip health, but don't expect clinical precision. A 1% error in the 3.3V reference voltage translates to roughly 4°C error in the reading. For accurate temperature measurement, use a dedicated external sensor (like an MCP9808 or DS18B20).
Checkpoint — ADC Basics
You can read analog values and convert them to physical units. The key steps are always the same: read raw value → convert to voltage → convert to physical unit.
Part 2: Light Sensors — Voltage Dividers in Action (20 min)
Background: Why Sensor Readings Jump Around
You might expect ADC readings to be perfectly stable, but every analog reading has noise — small random variations around the true value. Common sources on your robot: motor switching injects electrical spikes, ADC quantization flickers between adjacent levels, ambient light shifts optical sensor baselines, and mechanical vibration changes sensor-to-surface distance. The simplest fix is a moving average — average the last 3–5 readings to smooth noise while preserving real changes.
How Photoresistors Work
Your robot has two photoresistors (Light Dependent Resistors / LDRs). A photoresistor changes its resistance based on light intensity:
- Bright light → low resistance → lower voltage at ADC pin
- Darkness → high resistance → higher voltage at ADC pin
Each photoresistor forms a voltage divider with a fixed resistor on the board. As the photoresistor's resistance changes with light, the voltage at the ADC pin changes proportionally.
Voltage Divider Refresher
A voltage divider splits an input voltage between two resistors:
$\(V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2}\)$
When \(R_1\) is the photoresistor and \(R_2\) is fixed: bright light makes \(R_1\) small → \(V_{out}\) drops. This is why brighter = lower ADC value — the opposite of what you might expect!
Pin Mapping
| Sensor | GPIO | ADC Channel | Notes |
|---|---|---|---|
| Light Sensor 2 | GP26 | ADC0 | Always available |
| Light Sensor 1 | GP27 | ADC1 | Requires J7 jumper in "Light" position |
⚡Hands-on tasks
✅ Task 3 — Read Light Sensor
from machine import ADC, Pin
import time
# Light Sensor 2 on GP26 (ADC0)
light = ADC(Pin(26))
while True:
raw = light.read_u16()
print(f"Light: {raw}")
time.sleep(0.2)
Try it: Cover the sensor with your hand, then shine your phone's flashlight on it. Watch how the value changes.
Note the direction: brighter light → lower value. This catches many beginners off guard!
✅ Task 4 — Calibrate and Convert to Percentage
Raw ADC values aren't very meaningful. Let's calibrate:
- Cover the sensor completely → record the value (this is your
max_val— darkest) - Shine a bright light on it (you can use your phone torch) → record the value (this is your
min_val— brightest)
from machine import ADC, Pin
import time
light = ADC(Pin(26))
# --- CALIBRATION ---
# Replace these with YOUR measured values!
min_val = 500 # Bright light (lowest reading)
max_val = 55000 # Complete darkness (highest reading)
while True:
raw = light.read_u16()
# Clamp to calibration range
clamped = max(min_val, min(raw, max_val))
# Convert: 100% = bright, 0% = dark
percent = ((max_val - clamped) / (max_val - min_val)) * 100
bars = int(percent / 5)
bar = "█" * bars + "░" * (20 - bars)
print(f"Raw: {raw:5d} Light: {percent:5.1f}% |{bar}|")
time.sleep(0.2)
Prediction Challenge
Before testing each condition, predict the ADC value. Then verify.
| Condition | Your Prediction | Actual Value | Correct? |
|---|---|---|---|
| Hand covering sensor | |||
| Normal room light | |||
| Phone flashlight close | |||
| Phone flashlight far |
Checkpoint — Sensor Calibration
You can read analog sensors, calibrate them against known conditions, and convert raw readings to meaningful percentages. This calibration technique works for any analog sensor.
Part 3: Battery Voltage — Why You Can't Just Read It (15 min)
The Problem
Your robot's Li-Ion battery operates between 3.0V (empty) and 4.2V (full). But the ADC can only measure voltages from 0V to 3.3V (the reference voltage). Feeding 4.2V directly into the ADC would damage it!
The Solution: Voltage Divider
The robot board has a voltage divider (10kΩ + 5.1kΩ resistors) that scales the battery voltage down to a safe range:
So to get the actual battery voltage, multiply the ADC reading by 3:
This is connected to GP28 (ADC2).
⚡Hands-on tasks
✅ Task 5 — Read Battery Voltage
from machine import ADC, Pin
import time
# Battery voltage through voltage divider on GP28 (ADC2)
battery_adc = ADC(Pin(28))
while True:
raw = battery_adc.read_u16()
v_adc = (raw / 65535) * 3.3
v_battery = v_adc * 3 # Voltage divider ratio
print(f"Raw: {raw} V_adc: {v_adc:.2f}V V_battery: {v_battery:.2f}V")
time.sleep(1)
✅ Task 6 — Estimate State of Charge
A simple linear estimate of battery remaining:
Clamp to 0–100% to avoid nonsense values:
from machine import ADC, Pin
import time
battery_adc = ADC(Pin(28))
while True:
raw = battery_adc.read_u16()
v_adc = (raw / 65535) * 3.3
v_battery = v_adc * 3
# State of charge (linear estimate)
soc = ((v_battery - 3.0) / (4.2 - 3.0)) * 100
soc = max(0, min(100, soc)) # Clamp to 0-100%
# Visual battery bar
bars = int(soc / 10)
bar = "█" * bars + "░" * (10 - bars)
print(f"Battery: {v_battery:.2f}V SoC: {soc:.0f}% [{bar}]")
time.sleep(2)
Try this: Toggle the main power switch or connect/disconnect the USB charger. Watch how the voltage reading changes.
Why Voltage-Based SoC Is Limited
Li-Ion batteries have a very flat discharge curve — the voltage stays nearly constant between 20% and 80% charge. A voltage-based estimate is accurate near full and near empty, but unreliable in the middle. Professional battery management systems use coulomb counting (integrating current over time) for better accuracy.
Checkpoint — Battery Monitoring
You understand why voltage dividers are needed for measuring voltages above the ADC reference, and you can monitor battery health. This same technique applies whenever you need to measure a voltage that exceeds the ADC's input range.
What You Discovered
The ADC Pipeline
Physical World → Sensor/Divider → Voltage (0–3.3V) → ADC → Raw Number → Your Code
(temperature) (on-chip) (0.706V) (12-bit) (17500) (24.3°C)
Every analog measurement follows this chain. Understanding each step helps you debug when readings don't make sense.
Key Concepts
| Concept | What You Learned |
|---|---|
| ADC | Converts continuous voltage to a discrete number |
| Resolution | 12-bit ADC → 4096 levels, reported as 16-bit (0–65535) |
| Voltage conversion | V = (raw / 65535) × 3.3 |
| Voltage divider | Scales down voltages that exceed the ADC's 3.3V reference |
| Calibration | Map raw sensor values to meaningful units using known reference points |
What's Next?
In Pixels on a Tiny Screen, you'll display these sensor readings on the robot's OLED screen — no more squinting at the serial terminal!
Challenges (Optional)
These build on what you've learned. Each one adds a new idea — try them in order.
Useful references: ADC Basics · Temperature Sensor · Light Sensor · Battery Voltage · PicoCar Pinout · Key Code Reference
✅ Challenge 1 — Multi-Sensor Dashboard
Print temperature, light, and battery in one formatted output that updates every second.
Hints
- You need three ADC channels:
ADC(ADC.CORE_TEMP),ADC(Pin(26)),ADC(Pin(28)) - Reuse the conversion formulas from the tasks above (temperature, light percentage, battery voltage)
- Use f-string formatting to align columns:
f"Temp: {temp:5.1f}°C | Light: ..." - Print column headers once before the loop so the output looks like a table
✅ Challenge 2 — Light-Activated Alarm
Make the buzzer beep and LEDs turn red when someone covers the light sensor (dark = high ADC value). When uncovered, turn everything off. See Task 4 for calibration and the picobot Library for beep() and set_leds().
Hints
- Read the light sensor with
ADC(Pin(26)).read_u16()and compare against a threshold (~45000) robot.beep(880, 50)for the alarm,robot.set_leds((50, 0, 0))for red warning- Problem: at the threshold boundary, the alarm flickers on/off rapidly
- Fix: add hysteresis — trigger at 45000, but only stop at 35000. Use an
is_alarmingflag to track state
✅ Challenge 3 — Buzzer: Frequency & Duty Cycle Explorer
Use raw PWM to drive the buzzer directly and hear how frequency controls pitch and duty cycle controls volume.
from machine import Pin, PWM
import time
buzzer = PWM(Pin(22)) # buzzer pin — check your pinout
# Sweep frequency at 50% duty
buzzer.duty_u16(32768) # 50% duty cycle
for freq in range(100, 5001, 100):
buzzer.freq(freq)
time.sleep_ms(100)
buzzer.duty_u16(0) # silence
time.sleep(1)
# Sweep duty cycle at fixed frequency (2 kHz)
buzzer.freq(2000)
for duty in range(0, 65536, 1024):
buzzer.duty_u16(duty)
time.sleep_ms(50)
buzzer.duty_u16(0) # silence
What to observe
- Frequency sweep: pitch rises. Around 2–4 kHz the buzzer is noticeably louder — that's the resonant frequency.
- Duty sweep: at 2 kHz, volume changes from silent (0%) to loudest (~50%) and back down toward 100%.
- 50% duty = maximum volume — the piezo deflects equally in both directions.
Think about it
Why is robot.beep(freq, volume) more useful than raw PWM? What does the library do for you? (Hint: pin number, cleanup, timing.)
✅ Challenge 4 — Both Light Sensors
If your J7 jumper is set to "Light", you have two light sensors: GP26 (Light Sensor 2) and GP27 (Light Sensor 1). Read both and determine which side is brighter. See Light Sensor Reference and PicoCar Pinout for pin mapping.
Hints
- Create two ADC objects:
ADC(Pin(26))andADC(Pin(27)) - Compute the difference between left and right readings
- Classify: small difference (< 2000) = "BALANCED", otherwise indicate which side is brighter
- Test by shining your phone flashlight from different angles
Think about it
This is the same principle as the line sensors — comparing left vs right to determine direction. The line sensors give digital (0/1), the light sensors give analog (0–65535). Which is more useful for a "follow the flashlight" robot? What are the trade-offs?
✅ Challenge 5 — Data Logger with Statistics
Record 30 seconds of light sensor data (one sample per second), then compute and print min, max, average, and spread.
Hints
- Store readings in a list using
append()inside aforloop - Use
min(),max(),sum()/len()for statistics - Classify noise: spread < 500 = very stable, < 2000 = normal, > 2000 = noisy
- Extension: run this for temperature AND light — which sensor has more noise? Try with motors on vs off — does motor switching affect ADC readings?
Recap
The ADC converts continuous voltages into numbers your code can process. The RP2350's 12-bit ADC reads 0–3.3V and reports values as 16-bit for cross-platform consistency. Voltage dividers scale down higher voltages to the safe ADC range. Raw readings become meaningful through calibration against known reference points.
Key Code Reference
from machine import ADC, Pin
# Internal temperature sensor
temp_adc = ADC(ADC.CORE_TEMP)
raw = temp_adc.read_u16() # 0–65535
voltage = (raw / 65535) * 3.3 # Convert to volts
temp = 27 - (voltage - 0.706) / 0.001721 # RP2350 formula
# External analog sensor (e.g., light sensor on GP26)
light = ADC(Pin(26))
raw = light.read_u16()
# Battery voltage (GP28, through voltage divider)
battery = ADC(Pin(28))
v_battery = (battery.read_u16() / 65535) * 3.3 * 3 # ×3 for divider
← GPIO & Sensors | Labs Overview | Next: Pixels on a Tiny Screen →