Skip to content

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:

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 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:

\[T = 27 - \frac{V_{adc} - 0.706}{0.001721}\]

Where \(V_{adc}\) is the voltage at the ADC input:

\[V_{adc} = \frac{\text{raw}}{65535} \times 3.3\]
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.

→ Deep dive: Filtering Techniques

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:

  1. Cover the sensor completely → record the value (this is your max_val — darkest)
  2. 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:

\[V_{adc} = V_{battery} \times \frac{5.1}{10 + 5.1} \approx V_{battery} \times \frac{1}{3}\]

So to get the actual battery voltage, multiply the ADC reading by 3:

\[V_{battery} = V_{adc} \times 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:

\[SoC(\%) = \frac{V_{battery} - 3.0}{4.2 - 3.0} \times 100\]

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_alarming flag 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)) and ADC(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 a for loop
  • 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 →