Lecture 02: Sensors, Signals & Actuators
Week 3 | Labs completed: Robot Unboxing, GPIO & Sensors, Analog Sensors, OLED Display
Today's Map
From raw voltage to digital value to motor power — and why every step introduces error.
- Lab Recap & Bridge
- Sensor Interfaces — Where Does Conversion Happen?
- ADC Deep Dive
- Communication — I2C & the OLED
- PWM & Motor Basics
- Ultrasonic Ranging
- Calibration & Error
- Summary & Quick Checks
Section 1
Lab Recap: What You Discovered
Weeks 1--2: What You Did
| What You Did | What You Discovered |
|---|---|
Controlled LEDs with robot.set_leds() |
Digital signals can switch devices ON/OFF |
Made sounds with robot.beep() |
Frequency determines pitch — this is PWM |
| Configured GPIO pins as input/output | Digital pins drive LEDs or read sensors |
| Read optocoupler line sensors (digital) | HIGH/LOW tells you "line" or "no line" |
| Read light, temperature, battery via ADC | Sensors output a continuous voltage, not a label |
| Displayed values on the OLED (I2C) | I2C bus, real-time feedback without serial |
The Core Discovery
The world is analog, but the CPU needs numbers.
Temperature is represented by a continuous voltage. Light intensity varies smoothly. Battery voltage drifts over hours.
You used read_u16() and got numbers on your OLED — but what happened inside between the voltage and the number?
And how will we control motor speed when GPIO can only output HIGH or LOW?
Notice something about your code so far: you called robot.beep(), robot.set_leds(), robot.read_distance() — the library hid all the hardware details. Today we look under the hood. Later (Lecture 4) we'll talk about why libraries are structured this way.
What You Already Know (From Other Courses)
You are not starting from zero — today's topics connect to things you've seen elsewhere:
| From... | You already know... | Today we use it for... |
|---|---|---|
| Electronics | Voltage dividers, resistors, Ohm's law | Understanding how analog sensors produce voltage |
| Electronics | Binary numbers, bits, resolution | ADC quantization — how many levels fit in N bits |
| Programming | Loops, variables, reading input | Writing sensor-reading code, filtering, calibration |
| Physics | Sound travels at ~343 m/s | Ultrasonic distance measurement |
| Lecture 1 | GPIO, HIGH/LOW, digital pins | Building on this: what if the sensor isn't just 0 or 1? |
Today we go deeper into the "how" — not just reading a sensor, but understanding what happens between the physical world and the number in your variable.
Bridge: What Comes Next
You can sense (ADC, digital GPIO) and display (OLED via I2C). Things worked — but why?
- What happens inside the ADC between the voltage and the number?
- What's actually going on the I2C wires when you call
oled.show()? - How will you control motor speed when a GPIO pin only knows ON and OFF?
- How do you measure distance with sound — and why does it take 23 ms?
Today: we open these black boxes.
Section 2
How Sensors Talk to Your CPU
From raw voltage to digital bus — a spectrum, not a binary
Your Robot's Sensors — The Big Picture
Every sensor measures something physical. But the CPU only understands numbers. How do we get from physics to code?
| Sensor on Robot | Measures | Connection | You Read |
|---|---|---|---|
| Light sensor (LDR) | Light intensity | Analog → ADC pin | ADC.read_u16() |
| Line sensor (optocoupler) | Surface reflectance | Comparator → GPIO | Pin.value() |
| Ultrasonic (HC-SR04) | Distance | Pulse timing → GPIO | time_pulse_us() |
| IMU (BMI160) | Acceleration, rotation | I2C bus | i2c.readfrom() |
| OLED (SSD1306) | — (output) | I2C bus | i2c.writeto() |
Today we go through each one: how the component works, how it's connected, and what your code actually does with it. Ultrasonic and I2C get their own sections later.
5-Step Sensor Integration Process
Every sensor you will ever integrate follows the same pattern:
1. Understand What does it measure? Output range? Limitations?
↓
2. Connect Wire it: voltage levels, pull-ups, current limits
↓
3. Read Write code to get raw values
↓
4. Process Filter noise, calibrate, validate
↓
5. Use Feed into your control loop
We'll follow these steps for each sensor on your robot.
The Light Sensor — What Is an LDR?
A Light Dependent Resistor (LDR) — cadmium sulphide track whose resistance changes with light:

- More light → lower resistance
- Less light → higher resistance
- Non-linear characteristic
The Light Sensor — Characteristic

Resistance drops steeply at first, then flattens.
- Very sensitive in dim conditions
- Less sensitive in bright conditions
- You need calibration to map readings to useful values
The Light Sensor — Your Robot's Circuit
Your robot has two LDRs in voltage dividers — one per side:

| Component | Role |
|---|---|
| R3/R2 (10K) | Pull-up to 3.3V |
| R5/R4 (LDR) | Resistance drops with light |
| C2/C1 (100nF) | Filters PWM noise |
| Light | LDR R | Vout | Why |
|---|---|---|---|
| Bright | ~1 kΩ | ~0.3V | LDR pulls toward GND |
| Dark | ~50 kΩ | ~2.75V | Pull-up wins → near 3.3V |
The Light Sensor — In Action

LDR changes resistance → voltage divider converts to voltage → ADC converts to number. This is the raw analog pattern: you do all the work.
The Line Sensor — How It Works
Optocoupler array — analog inside, digital output:

- IR LED shines → surface reflects → phototransistor picks up the signal
- A voltage comparator (LM324) converts the analog level to a clean HIGH/LOW
- Threshold potentiometer sets the switching point
- Your robot reads these as
Pin.value()→ 0 or 1
The Line Sensor — Circuit

Left: 5 IR emitter/receiver pairs (tracking circuit). Right: LM324 quad comparator sets threshold.
- Analog reflectance → comparator → clean digital HIGH/LOW
- Trade-off: lost "how much" — only "is there a line?"
- Some boards also expose the raw analog output for proportional control
Where Does Conversion Happen?
The real distinction is not "digital vs analog sensor" — it's who converts physics to a number:
| Sensor | Analog element | Who does ADC? | Interface |
|---|---|---|---|
| Light (LDR) | Voltage divider | You (Pico ADC) | ADC pin |
| Line sensor | IR phototransistor | Comparator on board | GPIO pin |
| Ultrasonic | Sound travel time | Your timer code | GPIO + timing |
| IMU (BMI160) | MEMS accelerometer | Sensor's internal ADC | I2C bus |
The physics is always analog. The question is where the digitization happens — and that determines how you connect and read it.
We covered light and line sensors. Ultrasonic gets a deep dive in Section 6, I2C sensors in Section 5. Next: how the ADC actually works inside.
Section 3
ADC Deep Dive
From continuous voltage to binary number — step by step
The Big Picture: From Voltage to Number
Your sensor outputs 1.65V. Your code sees 2047. What happened in between?
The CPU only understands numbers — it cannot work with voltages directly. The ADC's job is to measure the voltage and give the CPU a number that represents it.
Think of it like a ruler: - The voltage is the length you're measuring - The ADC levels are the tick marks on the ruler - More bits = finer tick marks = more precision
Voltage ──→ [Sample] ──→ [Quantize] ──→ [Encode] ──→ Binary Number
(1.65V) (freeze it) (snap to grid) (to bits) (0x7FF = 2047)
The ADC does this thousands of times per second. Understanding each step tells you where precision is lost and how fast you can measure — both critical for control loops.
The Analog-to-Digital Pipeline
Three steps transform a continuous voltage into a binary number the CPU can process:
- Sample — capture the voltage at a specific instant
- Quantize — snap it to the nearest discrete level
- Encode — represent that level as a binary number
Get any of these steps wrong and the digital value misrepresents reality. Sensor accuracy is your ceiling.
Step 1 — Sampling
Capture the analog voltage at a specific instant in time.
Inside the ADC, a sample-and-hold circuit "freezes" the voltage so the conversion can proceed on a stable value.

The sampling rate (samples per second) determines how often this happens. Your RP2350's hardware ADC can sample at up to 500 kS/s — but in MicroPython, read_u16() takes ~100 µs of Python overhead, limiting you to ~10,000 reads/second in practice.
For most sensors on the PicoBot (line sensors, distance sensors), 10 kS/s is more than enough. The bottleneck is usually your control loop, not the ADC.
Step 2 — Quantization
Map the sampled voltage to the nearest discrete level.
With n bits, you get \(2^n\) levels. The continuous voltage is forced onto a staircase.

The "staircase" is the price of digitization — you lose the information between steps.
Quantization Error
The difference between the true voltage and the quantized value is the quantization error.
- Maximum error = ±½ LSB (least significant bit)
- More bits = smaller steps = less error
True voltage: 1.6508 V
Nearest 12-bit level: 1.6504 V (level 2048)
Quantization error: 0.0004 V (less than ½ LSB = 0.4 mV)
Quantization Error
For the RP2350's 12-bit ADC: ½ LSB = 0.4 mV. This is far smaller than typical sensor noise.
Quantization error is usually NOT your bottleneck. Electrical noise and sensor imprecision dominate.

Step 3 — Encoding
Convert the quantized level to a binary number.
- 12-bit ADC → 4096 levels → binary numbers
0b000000000000to0b111111111111 - Hex:
0x000to0xFFF - Decimal: 0 to 4095
MicroPython's read_u16() scales the 12-bit result to 16-bit (0--65535). The extra bits are padding, not additional precision.
Resolution: How Many Levels?
With n bits, the voltage resolution (smallest detectable change) is:
| Bits | Levels | Step size (at 3.3 V) | Can distinguish... |
|---|---|---|---|
| 8 | 256 | 12.9 mV | Coarse changes |
| 10 | 1024 | 3.2 mV | Moderate detail |
| 12 | 4096 | 0.8 mV | Fine detail |
The RP2350 has a 12-bit ADC — it can distinguish voltage changes as small as 0.8 mV.
Worked Example: Voltage → Digital Value
If we measure 1.65 V with a 12-bit ADC (3.3 V reference):
The ADC outputs 2047 — exactly half-scale, as expected for half the reference voltage.
Going the other way: if the ADC reads 3000, the original voltage was:
For analog sensors, a bright light might read ~9900 and darkness ~49700 (16-bit scaled). Plenty of range to distinguish gradual changes.
SAR ADC — How It Works Inside
The RP2350's SAR ADC uses binary search — the same algorithm you'd use to guess a number:
"I'm thinking of a number between 0 and 3.3..." "Is it above 1.65?" → "Yes" → search upper half "Is it above 2.475?" → "No" → search lower half "Is it above 2.0625?" → "Yes" → search upper half ...repeat 12 times → found it to 0.8 mV precision!
Each guess = one bit of the result. 12 guesses = 12 bits = done in ~2 µs.
SAR ADC — Step 1: Try the MSB
The RP2350's SAR ADC finds the digital value by binary search. It starts with the most significant bit:

The DAC outputs \(V_{ref}/2\) = 1.65V. The comparator checks: is the input higher? Yes → keep bit 11 = 1, search the upper half next.
SAR ADC — Step 2: Next Bit
Now try bit 10 — the DAC jumps to 3/4 of \(V_{ref}\):

DAC = 2.475V — higher than Vin! Discard → bit 10 = 0, search lower. The staircase steps down.
SAR ADC — Steps 3–6: The Full Staircase
Each step is half the size of the previous. The staircase zigzags up and down, converging on Vin:

The bit results appear below: 1 0 1 0 1 0 — after 6 steps, the DAC is within millivolts. A real 12-bit SAR does this 12 times in ~2 µs.
SAR ADC Architecture
┌──────────────────────────────────────┐
│ Feedback loop │
▼ │
Vin ──→ [Sample & Hold] ──→ [Comparator +/−] ──→ [SAR Controller] ──→ Digital Output
freeze higher/lower? │ (12-bit result)
voltage │
▲ │
│ │
[DAC] ◄────────────┘
reference voltage bit pattern
The SAR controller tries bit patterns through the DAC. The comparator says higher or lower. 12 iterations → 12 bits → ~2 µs.
Sample & Hold: Why the Voltage Must Be Stable
Imagine trying to measure someone's height while they're jumping. You'd get different answers depending on when you look. The solution: ask them to stand still for 2 seconds.
The ADC has the same problem. The SAR needs 12 clock cycles to decide all bits. If the input voltage changes during that time, earlier bits become wrong — the staircase converges on a moving target.

A sample-and-hold circuit freezes the voltage before conversion starts. The signal keeps changing, but the ADC works with the frozen value.
Sample & Hold: The Circuit
A switch and capacitor form the S&H. While closed, the capacitor tracks the input. When it opens, the charge is trapped — voltage frozen.

The ADC converts the frozen capacitor voltage while the real signal continues changing.
Why Sampling Rate Matters
How often you sample determines what signals you can faithfully capture:
- Slowly changing (temperature, battery voltage) → 1--10 Hz sampling is fine
- Moderately changing (robot position on line) → 50--200 Hz
- Fast changing (vibration, audio, motor noise) → kHz to MHz

Too slow = you miss changes and get the wrong signal. This has a name: aliasing.
The Nyquist Rule
To faithfully capture a signal, you must sample at at least 2x the signal frequency:
This is the Nyquist criterion. Below this rate, aliasing occurs — the data shows a false signal.
| Signal Frequency | Minimum Sample Rate | Practical Rate (5--10x) |
|---|---|---|
| 1 Hz (temperature) | 2 Hz | 5--10 Hz |
| 50 Hz (mains hum) | 100 Hz | 250--500 Hz |
| 20 kHz (audio) | 40 kHz | 44.1 kHz (CD quality) |
Practical rule: Use 5--10x the signal frequency for safety margin against noise and timing jitter.
Aliasing — What Happens When You Sample Too Slow
The sampled data looks like a different, slower signal. From the data alone, you cannot tell which signal was real.
Real-world example: In movies, wagon wheel spokes sometimes appear to spin backward. The camera samples at 24 fps — if the spoke frequency is near a multiple of 24 Hz, you see an alias.
Aliasing is not recoverable. Once the data is sampled, the original signal is lost. Prevention (sampling fast enough) is the only solution.
Your Robot's ADC
| Parameter | Value |
|---|---|
| Type | SAR (Successive Approximation) |
| Resolution | 12-bit (4096 levels) |
| Sample rate | 500 kS/s |
| Effective bits | 9.2 ENOB (Effective Number of Bits) |
| Voltage range | 0 -- 3.3V |
| Step size | 3.3V / 4096 = 0.8 mV per step |
| ADC channels | 4 external + 1 internal (temperature) |
- For temperature (changes in seconds): 1 Hz sampling is plenty
- For motor noise (kHz range): 500 kS/s needed to see it
- For line following (changes at ~50 Hz): 200 Hz is comfortable
Beyond SAR: Other ADC Architectures
Your robot uses SAR — but it's not the only way to convert analog to digital:
| Architecture | Speed | Resolution | Used In |
|---|---|---|---|
| SAR | Fast (500 kS/s) | 12--18 bit | MCUs (RP2350, STM32, ESP32) |
| Sigma-Delta (ΔΣ) | Slow (10--1000 S/s) | 24 bit | Precision measurement, audio, scales |
| Flash | Very fast (>1 GS/s) | 6--8 bit | Oscilloscopes, video, radar |
| Pipeline | Fast (10--200 MS/s) | 10--14 bit | Communication receivers, imaging |
Design choice: SAR is the universal compromise — fast enough for control loops, precise enough for sensors. If you need extreme precision (weighing scale), use ΔΣ. If you need extreme speed (oscilloscope), use Flash. Each trades one property for another.
ADC in MicroPython
Resolution math:
- 12 bits = 4096 levels
- 3.3V / 4096 = 0.8 mV per step
- read_u16() scales to 0--65535 (16-bit range) — the extra bits are padding, not additional precision
Practical range — light sensor:
| Condition | ADC count (12-bit) | read_u16() |
|---|---|---|
| Bright | ~620 | ~9929 |
| Dark | ~3100 | ~49680 |
| Usable range | ~2480 levels | ~39750 |
~2480 distinct levels — the ADC is not the bottleneck for analog sensors.
From Raw Reading to Useful Value
What your code typically does with a sensor reading:
🔌 Read 🔧 Filter 📐 Calibrate ✅ Validate 📊 Use
read_u16() → smooth_read() → normalize() → bounds check → control loop
raw cleaned meaningful trusted decision
noisy stable value value
This read → filter → calibrate → validate pipeline appears in every sensor-driven system. Right now you do it in a few lines; later we'll organize it into proper functions and modules.
Sensor Noise: Why Readings Jump
You might expect sensor readings to be perfectly stable when nothing changes. In reality, every reading has noise:
Expected (ideal): What you actually get:
50000 51200
50000 49300
50000 50800
50000 48700
50000 50500
| Source | Effect | When It's Worst |
|---|---|---|
| EMC / Electrical noise | Motor switching, H-bridge PWM, shared power rails inject spikes and ripple into analog signals | Motors running, servo pulses, nearby switching regulators |
| ADC quantization | Readings flicker between two adjacent levels | Signal near a step boundary |
| Ambient light | Room lighting changes the baseline of optical sensors | Near windows, flickering lights |
| Mechanical vibration | Motor vibration shakes the sensor physically | While driving |
EMC (Electromagnetic Compatibility) is why your 100nF capacitor on the light sensor matters — it filters high-frequency switching noise from the motors and power supply.
The Simplest Fix: Moving Average
Instead of trusting a single reading, average the last N readings:
# Simple moving average (N = 5)
history = [0] * 5
index = 0
def smooth_read(sensor):
global index
history[index] = sensor.read_u16()
index = (index + 1) % len(history)
return sum(history) // len(history)
Trade-off: Larger window = less noise but slower response. For sensors at moderate update rates, N = 3--5 is a good starting point.
Software pattern: This is your first data processing pipeline — raw data in, processed data out. The hardware gives you noisy numbers; your code's job is to turn them into useful values. We'll build more of these: filter → calibrate → validate → use.
Filtering in Action

Raw signal (noisy) vs moving average (smooth but delayed). The filter removes noise but introduces lag — a fundamental trade-off you'll encounter in every control loop.
Section 4
PWM & Motor Basics
Controlling power from a digital pin — preview for next week's lab
The Big Picture: From Sensing to Acting
You can sense (ADC, GPIO), communicate (I2C), and display (OLED). But the robot still cannot move with precision. You called robot.forward() — but what controls the speed?
The key technique: PWM — using fast switching to control power from a pin that only knows ON and OFF.
┌──────────── feedback ◄────────────┐
│ │
👁 Sense 🧠 Decide 💪 Act
ADC, GPIO → your code → PWM → motor
The Problem: Controlling Power from a Digital Pin
A GPIO pin has only two states:
- HIGH = 3.3V (ON)
- LOW = 0V (OFF)
But you need intermediate levels: - LED at 50% brightness - Motor at 30% speed - Buzzer at a specific pitch
How do you get "in between" from a pin that only knows ON and OFF?
The trick: switch ON/OFF so fast that the device (and your senses) only perceive the average. It's like a light switch — flip it 1000 times per second with 50% ON time, and the room looks half-bright.
PWM — Switching Fast
Pulse Width Modulation: rapidly toggle between HIGH and LOW.
Two parameters: - Frequency — how fast you toggle (Hz) - Duty cycle — fraction of time spent HIGH (0--100%)
50% duty at 1 kHz means: HIGH for 0.5 ms, LOW for 0.5 ms, repeat 1000 times per second.
The load "sees" the average power — as if you applied a steady intermediate voltage.
PWM: From Blink to Smooth

At low frequency you see blinking. Increase frequency past ~50 Hz and persistence of vision turns it into smooth dimming. Same principle, different timescale.
How We See and Hear Intensity
Our senses are integrators — they don't respond to individual pulses, they respond to averages:
| Sense | What It Measures | Integration Time | "Fools" Above |
|---|---|---|---|
| Eye | Light energy over time | ~20 ms | ~50 Hz (flicker fusion) |
| Ear | Air pressure oscillation | Frequency-dependent | — (hears individual cycles) |
| Skin | Temperature (thermal mass) | Seconds | ~0.1 Hz |
Eye: sees average brightness — 50% ON at 1 kHz looks the same as steady 50% brightness.
Ear: does NOT average — it hears each cycle as a pitch. 1 kHz PWM = 1 kHz tone. But the loudness depends on how far the speaker cone moves (amplitude), which drops at higher frequencies.
This is why the same PWM signal creates "dimming" on an LED but a "tone" on a buzzer.

How Different Loads See PWM
The same PWM signal behaves differently depending on what it drives:
| Load | What Happens | Why |
|---|---|---|
| LED + eye | Smooth dimming | Persistence of vision (>50 Hz looks steady) |
| Buzzer | Pitch changes with frequency | Buzzer follows exactly — frequency IS the pitch |
| DC Motor | Smooth speed control | Mechanical inertia averages the pulses |
- For LEDs: duty cycle controls brightness, frequency just needs to be >50 Hz
- For buzzers: frequency controls pitch, duty cycle affects volume
- For motors: duty cycle controls speed, frequency needs to be >1 kHz (avoid audible whine)
Think of it like a stove: turning it on/off every 10 seconds → you feel the temperature swings. On/off every 0.1 seconds → the thermal mass of the pot integrates it into a smooth average temperature. The pot is the "filter."
How a Passive Buzzer Works
Your robot's buzzer is a passive buzzer — no built-in oscillator, just a piezoelectric ceramic disc on a metal plate.

DC voltage bends it once → one click, then silence. PWM alternation → continuous vibration → sound.
Buzzer Drive Circuit

The GPIO pin can't drive the buzzer directly — not enough current.
| Component | Role |
|---|---|
| Q1 (8050 NPN) | Transistor switch — GPIO controls base |
| R6 (1K) | Limits base current |
| R1 (4.7Ω) | Current limiting for buzzer |
| D1 (1N4148) | Flyback diode — absorbs voltage spike when transistor turns off |
Pattern: Same as H-bridge — MCU provides the signal (PWM), transistor switches the power (3.3V through buzzer). You'll see this transistor-as-switch pattern everywhere in embedded hardware.
Buzzer: Frequency Response
The piezo disc has mass — it can't deflect infinitely fast. Each buzzer has a resonant frequency where it's loudest:

- 100 Hz → full deflection → loud
- 1 kHz → partial deflection → medium
- 10 kHz → barely moves → quiet
- 2--4 kHz → resonance → loudest
Why this matters: If you pick a frequency far from resonance, the buzzer is barely audible. Check the datasheet for the resonant frequency to get maximum volume.
Buzzer: Deflection at Different Frequencies

Same 50% duty cycle, very different loudness — because the disc physically can't keep up at high frequency.
Buzzer: Frequency vs Duty Cycle

Frequency controls pitch (what note). Duty cycle controls waveform shape (timbre/harshness). The buzzer produces a real square wave — not a sine.
Why Look Under the Hood?
We just spent several slides understanding how a buzzer works physically. Why?
In embedded systems, you control hardware directly. If you don't understand how it works, you can't design, optimize, or debug effectively.
| Situation | Without understanding | With understanding |
|---|---|---|
| Buzzer too quiet | "Maybe increase duty?" | Check if frequency is near resonance |
| Motor doesn't start | "Increase PWM value?" | Dead zone — need minimum threshold |
| ADC reads noise | "Average more?" | Add filter cap, check EMC sources |
| Ultrasonic blocks loop | "Use faster sensor?" | Non-blocking timing pattern |
The pattern: look at the datasheet, understand the physics, then write code that works with the hardware, not against it. This mindset separates embedded engineering from general programming.
PWM in MicroPython
from machine import Pin, PWM
pwm = PWM(Pin(15))
pwm.freq(1000) # 1 kHz switching frequency
pwm.duty_u16(32768) # 50% duty (half of 65535)
| Duty Value | Duty % | Effect on LED | Effect on Motor |
|---|---|---|---|
| 0 | 0% | OFF | Stopped |
| 16384 | 25% | Dim | Slow |
| 32768 | 50% | Medium | Medium |
| 65535 | 100% | Full brightness | Full speed |
When you called robot.forward(speed=50), the library translated that into two PWM duty values — one per motor.
What Happens Under the Hood
You're already using PWM — now you know what happens underneath:
Your code robot.forward(50)
│
▼
Robot library motors.set_speed(left, right)
│
▼
MicroPython HAL PWM.duty_u16(32768)
│
▼
Hardware register Timer/Counter peripheral
│
▼
⚡ Pin toggles at 1 kHz
Each layer hides detail from the one above. We'll explore this abstraction pattern in Lecture 4.
Preview: Motors and H-Bridges
Next week you'll use PWM to drive motors. The key hardware:
- H-bridge (AM1016A): switches battery current based on your PWM signal
- Dead zone: below ~25% duty, motors don't move (static friction)
- Signal vs Power: your GPIO provides direction, the H-bridge provides energy
Details in Lecture 3 — after you've experienced it in the Motor Control lab.
Section 5
Communication — I2C & the OLED
Two wires, many devices
Why Analog Wires Are Not Enough
You used i2c.scan() in the OLED lab — it found devices at addresses 0x3C and 0x69. But what is actually happening on those two wires?
One analog wire carries one voltage. Your OLED needs 512 bytes per frame. The IMU sends 6 axes of data. A GPS sends full text sentences. One voltage cannot carry structured data.
Digital communication protocols solve this — structured, addressable data over shared wires. The three most common in embedded systems: I2C, SPI, and UART.
I2C — Two Wires, Many Devices
SDA (data) + SCL (clock) — just 2 wires shared by all devices.
Think of it like a classroom: - The teacher (master = Pico) calls a student by name (address) - Only that student responds — everyone else stays quiet - They take turns talking on the same pair of wires
Each device has a unique 7-bit address (up to 127 devices on one bus):
- OLED display: 0x3C
- BMI160 IMU: 0x69
The master (Pico) initiates all communication. Devices only respond when addressed.
Pull-up resistors keep both lines HIGH when idle — devices pull lines LOW to signal.

I2C Protocol Basics
The master controls the clock and initiates every transaction:
START → Address (7 bits) → R/W bit → ACK → Data bytes → STOP
| Clock Speed | Name | Your Robot Uses |
|---|---|---|
| 100 kHz | Standard | — |
| 400 kHz | Fast | OLED and IMU |
| 1 MHz | Fast+ | — |
I2C in MicroPython
from machine import I2C, Pin
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000)
devices = i2c.scan() # Returns list of addresses
print(devices)
# [60, 105] → 0x3C (OLED), 0x69 (IMU)
i2c.scan() sends the address of every possible device (0--127) and listens for an ACK. Only connected devices respond.
This is the first thing to try when debugging I2C: if scan() finds the device, the wiring is correct.
The OLED Display — SSD1306
Your robot's OLED: 128 x 32 monochrome pixels, I2C address 0x3C.
Framebuffer concept:
1. Draw in memory (fast — just writing to RAM)
2. Call oled.show() — sends the entire buffer over I2C
3. Display updates all at once
Buffer size: 128 x 32 / 8 = 512 bytes
At 400 kHz I2C, sending 512 bytes takes approximately 10 ms — this is why oled.show() is a blocking call. Your code pauses while the data transfers.
How an OLED Display Works
Unlike an LCD, each OLED pixel emits its own light — no backlight needed.
Organic Light-Emitting Diode: a thin organic compound between two electrodes. Apply voltage → it glows.
| Feature | OLED | LCD |
|---|---|---|
| Light source | Each pixel self-emits | Backlight behind all pixels |
| Black pixels | Truly OFF (no power) | Backlight still on, blocked |
| Contrast | Very high (∞:1 in theory) | Limited (~1000:1) |
| Viewing angle | Wide | Narrower |
| Power (dark screen) | Very low | Same as bright screen |
Your SSD1306 is monochrome — each pixel is either on or off, controlled by a single bit. That's why the framebuffer is only 512 bytes: 128 × 32 pixels / 8 bits = 512.
What oled.show() Actually Does
When you called oled.show() in the lab, this is what happened behind the scenes:
📝 Framebuffer 🧠 SSD1306 Controller 💡 128×32 OLED Pixels
512 bytes in RAM ───→ display driver IC ───→ each pixel ON or OFF
I2C write row/column
~10 ms drivers
- Draw in RAM —
oled.text("Hello", 0, 0)writes pixels to a 512-byte array. Instant. - Send over I2C —
oled.show()sends all 512 bytes + protocol overhead. At 400 kHz, this takes ~10 ms. - Controller updates pixels — The SSD1306 IC maps each byte to 8 vertical pixels.
Why "framebuffer"? You draw to a frame in memory, then flush it all at once. This avoids flickering — the display never shows a half-drawn frame. This same pattern is used in GPUs, game engines, and embedded GUIs everywhere.
The Cost of oled.show()
In a 100 Hz loop, your time budget is 10 ms per cycle. One oled.show() takes ~10 ms — that's your entire budget gone.
Without OLED: [sensors|compute|motors|idle........] 10 ms
With OLED: [sensors|compute|motors|oled.show()!] 10 ms ← no time left!
Solutions: - Update every 5th or 10th loop iteration, not every cycle - Only update when data changes - Use a simpler display (7-segment) for real-time values
Software lesson: Every I/O operation has a time cost. In embedded systems, you must measure and budget your loop time. We'll revisit this concept in Lecture 4 when we talk about scheduling.
Display Alternatives — Choosing the Right Output
Not every project needs an OLED. Match the display to your needs:
| Display | Resolution | Interface | Cost | Best For |
|---|---|---|---|---|
| 7-segment LED | 1–8 digits | GPIO pins | €0.50 | Counters, clocks, temperature |
| Character LCD | 16×2 or 20×4 chars | I2C or parallel | €2 | Text menus, status messages |
| OLED SSD1306 | 128×32 or 128×64 | I2C / SPI | €3 | Graphics, small dashboards |
| TFT LCD | 240×320+ color | SPI | €5 | Color graphics, images |
| E-ink / E-paper | 200×200+ | SPI | €8 | Battery-powered, static info |
Design question: Do you need to update 100 times/second, or once per minute? A 7-segment LED updates in microseconds. A TFT at SPI speed. E-ink takes seconds but uses zero power between updates. Choose the display that matches your update rate and power budget.
Communication Protocols Comparison
| Protocol | Wires | Speed | Devices | Used For |
|---|---|---|---|---|
| Analog | 1 per sensor | N/A | 1 per pin | Simple sensors (temp, light) |
| I2C | 2 (shared) | 100--1000 kHz | Up to 127 | OLED, IMU, complex sensors |
| SPI | 4 + 1/device | 1--50 MHz | Few | Fast sensors, SD cards |
| UART | 2 (point-to-point) | 9600--115200 baud | 1 | GPS, Bluetooth modules |
I2C is the "universal compromise": only 2 wires, many devices, moderate speed. When you need more speed (camera, SD card), you move to SPI. When you need simplicity with one device (GPS), you use UART.
Section 6
Ultrasonic Ranging
Measuring distance with sound — and why 23 ms matters
The Big Picture: The Slow Sensor
In Section 2 we classified the ultrasonic sensor as "timed digital" — information encoded in a pulse width, not a voltage or bus message.
Now the practical consequence: this measurement takes up to 23 ms. That is 10,000x slower than reading a GPIO pin. In a 100 Hz control loop (10 ms budget), one ultrasonic reading blows your entire time budget.
Ultrasonic Ranging: Time of Flight

Think of it like shouting in a canyon — you yell, and the echo comes back. The longer the echo takes, the farther the wall. The sensor does the same thing but with ultrasound (40 kHz, too high for humans to hear).
Your robot's HC-SR04 measures distance using the speed of sound:
- Sound speed: 343 m/s at 20°C
- Max range: ~4 m → round trip 8 m
- Max round-trip time: 8 m / 343 m/s = 23.3 ms
- Divide by 2 because sound travels there and back
This 23.3 ms measurement time will matter — it blocks your entire loop.
HC-SR04 Timing Protocol

Three signals, strict sequence:
- Trigger — MCU sends a 10 us HIGH pulse
- Burst — sensor emits 8 cycles of 40 kHz ultrasound
- Echo — sensor holds echo pin HIGH proportional to round-trip time
The echo pin stays HIGH until the sound returns — or times out at ~23.3 ms.
The Blocking Problem
What robot.read_distance() hides:
def read_distance(trigger, echo):
# 1. Send 10 us trigger pulse
trigger.value(0)
time.sleep_us(2)
trigger.value(1)
time.sleep_us(10)
trigger.value(0)
# 2. Wait for echo (BLOCKS!)
pulse_us = time_pulse_us(echo, 1, 30000)
# 3. Convert to cm
if pulse_us < 0:
return -1 # timeout
return pulse_us / 58 # speed of sound conversion
The time_pulse_us() call blocks for up to 30 ms. During that time, your robot cannot read sensors, update the display, or adjust motors.
This week's lab: You will solve the blocking problem with non-blocking timing patterns.
Why Blocking Matters
At 30 ms per measurement, a 100 Hz control loop becomes a 33 Hz loop — or worse:
Without ultrasonic: |sense|compute|act|sense|compute|act| ← 10 ms loop
With blocking: |sense..........|compute|act| ← 40 ms loop
↑ 30 ms frozen ↑
Other sensors go unread. Motors do not get updated. The robot drives blind during the measurement.
Non-blocking patterns let you start the measurement, do other work, and check the result later. You will implement this in today's lab.
Blocking vs Non-Blocking — A Software Problem
The sensor works fine — it's your code structure that determines whether the robot freezes or stays responsive:
| Approach | How It Works | Code Style |
|---|---|---|
| Blocking | Call function, wait, get result | dist = read_distance() (simple but freezes) |
| Non-blocking | Start, do other work, check later | start_trigger() ... if echo_ready(): ... |
This is a software architecture problem, not a hardware problem. We'll revisit it with state machines in Lecture 4 — where it becomes a core design principle.
Section 7
Calibration & Error
Every measurement is wrong — the question is by how much
The Big Picture: No Measurement Is Perfect
Every number from every sensor is wrong — the question is by how much and can you fix it?
In the analog sensors lab, you saw readings jump around. In this section: why that happens, the three types of error, and the engineering workflow to minimize each one.
"A measurement without uncertainty is just a number. A measurement WITH uncertainty is engineering data."
Systematic vs Random vs Gross Error
There are three categories of sensor error:
| Error Type | Behavior | Example | How to Fix |
|---|---|---|---|
| Systematic | Consistent bias in one direction | Sensor always reads 5% high | Calibration (offset/gain correction) |
| Random | Unpredictable variation around true value | ADC readings fluctuate ±200 | Averaging / filtering |
| Gross | Obviously wrong readings | Sensor disconnected → reads 0 or 65535 | Bounds checking, outlier rejection |
Systematic error (bias): Random error (noise):
True: ─────────────── True: ───────────────
Measured: - - - - - - - - Measured: /\/\/\/\/\/\/\/\
(offset but parallel) (centered but noisy)
What Is Calibration?
Calibration = finding the mapping from raw sensor values to true physical values.
Think of a bathroom scale that always shows 2 kg too high. If you know it's always 2 kg high, you can subtract 2 from every reading. That subtraction is calibration.
📟 Raw reading 📐 Calibrated value ✅ Closer to true value
(biased) ───→ (corrected) ───→ (usable)
subtract offset,
apply scale
The largest error source is usually systematic — and systematic error is repeatable. If the error is the same every time, we can measure it and subtract it.
Light Sensor: Two-Point Calibration
Same principle you used in the analog sensors lab. Map the raw range to a normalized 0--100 scale:
# Measured during calibration:
CAL_BRIGHT = 9900 # read_u16() on bright surface
CAL_DARK = 49700 # read_u16() on dark surface
def normalize(raw):
"""Convert raw ADC to 0-100 scale."""
normalized = (raw - CAL_BRIGHT) / (CAL_DARK - CAL_BRIGHT)
return max(0, min(100, normalized * 100))
Now your code works the same regardless of ambient light level or sensor-to-surface distance.
Two reference points define a line: corrected = slope * raw + offset. This is the simplest calibration — and often sufficient.
Software pattern: Notice the separation of concerns — calibration values (
CAL_BRIGHT,CAL_DARK) are stored as constants, separate from the conversion function. Later we'll move these to a config file so you can re-calibrate without changing code.
General Calibration Workflow
The same process applies to every sensor — light, temperature, ultrasonic, gyroscope:
| Step | Action |
|---|---|
| 1. Collect | Apply known inputs (known distance, known temperature, known surface) |
| 2. Record | Take 20--50+ readings at each reference point |
| 3. Plot | Raw vs reference — is it linear? Is there an offset? |
| 4. Fit | calibrated = slope * raw + offset |
| 5. Validate | Check with NEW reference points (not the ones you calibrated with) |
| 6. Document | Record parameters, date, conditions |
Calibration is not permanent — temperature, battery level, and wear change sensor behavior. Re-validate periodically.
Gross Error: The One That Breaks You
Systematic and random errors cause small control errors. Gross errors cause maximum control errors.
A reading of 0 because a wire came loose. A reading of 65535 because the ADC saturated. A reading of -1 because the ultrasonic sensor timed out.
def safe_read(sensor, min_valid=100, max_valid=65000):
raw = sensor.read_u16()
if raw < min_valid or raw > max_valid:
return None # Gross error — do not trust this reading
return raw
Always implement bounds checking before using sensor data in control decisions.
The Measurement Mindset
Bad: "The sensor says 25 cm."
Good: "Over 50 readings, I measured 25.0 ± 0.4 cm (95% confidence)."
Every sensor reading has:
- A value (your best estimate)
- An uncertainty (how much you trust it)
- Conditions (when, how, and with what calibration)
A measurement without uncertainty is just a number. A measurement WITH uncertainty is engineering data.
Software Patterns — What We've Seen So Far
Today was mostly about hardware — but every hardware concept has a software side:
| Hardware Concept | Software Pattern | You'll Explore In... |
|---|---|---|
| ADC reads noisy values | Filtering — moving average smooths data | This lab |
| Sensor needs calibration | Config separation — constants, not magic numbers | Lab 3 |
| Ultrasonic blocks 23 ms | Non-blocking I/O — start, do other work, check later | This lab |
| PWM controls motors | Abstraction layers — robot.forward() hides details |
Lecture 4 |
| Gross errors crash control | Input validation — bounds check before using data | Lab 3 |
Where You Are — The Code Organization Journey
Right now your code is small snippets — a few lines in a while True loop. As you add more sensors, motors, and logic, you'll need structure.
📝 Snippet 🔧 Function 📦 Module 🏗 Architecture
(you are here) ──→ reusable code ──→ organized files ──→ state machines,
design patterns
Each lab adds complexity that motivates the next step. Software architecture comes in Lecture 4.
Section 8
Summary & Quick Checks
The Bigger Picture: Your Robot vs The Industry
Everything today was taught through YOUR robot — but these are universal patterns:
| Concept | Your Robot | Industrial Example |
|---|---|---|
| ADC | RP2350 12-bit SAR | 24-bit ΔΣ in a medical ECG |
| I2C bus | OLED + IMU on 2 wires | 50+ sensors on a satellite bus |
| PWM motor control | AM1016A at 3.7V/1A | BTS7960 at 24V/43A in an e-bike |
| Ultrasonic ranging | HC-SR04 hobby sensor | Industrial ToF LIDAR at 100m range |
| Calibration | 2-point light sensor | Multi-point with temperature compensation |
The patterns transfer. The specific ICs change. When you move to STM32, ESP32, or an industrial PLC, you will use the same concepts with different part numbers.
Block Summary: Sensor Interfaces
| # | Takeaway |
|---|---|
| 1 | The real question is where the ADC happens — you do it (analog pin), or the sensor does it (digital bus) |
| 2 | Sensors form a spectrum: raw analog → conditioned → timed pulse → digital GPIO → digital bus (I2C/SPI) |
| 3 | "Digital sensors" like the IMU are analog inside with a built-in ADC + processor |
| 4 | 5-step process applies to every sensor: understand, communicate, connect, implement, verify |
Block Summary: ADC
| # | Takeaway |
|---|---|
| 1 | Three steps: Sample → Quantize → Encode |
| 2 | SAR ADC uses binary search: 12 comparisons for 12-bit resolution at 500 kS/s |
| 3 | Nyquist rule: sample at least 2x the signal frequency to avoid aliasing |
| 4 | Aliasing produces a false slower signal — not recoverable after sampling |
| 5 | Quantization error (±½ LSB) is usually negligible compared to sensor noise |
Block Summary: I2C & Communication
| # | Takeaway |
|---|---|
| 1 | I2C uses 2 shared wires (SDA + SCL) with device addresses |
| 2 | Master initiates all communication — devices only respond when addressed |
| 3 | OLED framebuffer: draw in memory, show() sends 512 bytes (~10 ms blocking) |
| 4 | Choose protocol by need: I2C (many devices), SPI (speed), UART (simplicity) |
Block Summary: PWM & Motors
| # | Takeaway |
|---|---|
| 1 | PWM toggles HIGH/LOW rapidly — the load sees average power |
| 2 | Duty cycle controls power, frequency must suit the load type |
| 3 | H-bridge separates signal (MCU) from power (battery) — never shoot-through |
| 4 | Dead zone (~20--30%) means low duty = no movement — must find experimentally |
Block Summary: Ultrasonic & Calibration
| # | Takeaway |
|---|---|
| 1 | Time of flight: d = v*t/2, max 23.3 ms measurement blocks the loop |
| 2 | Non-blocking patterns let you do other work while waiting for the echo |
| 3 | Three error types: systematic (calibrate), random (filter), gross (reject) |
| 4 | Measurement = value + uncertainty + conditions |
Quick Check 1
What are the three steps of analog-to-digital conversion?
Quick Check 1 -- Answer
Sample, Quantize, Encode.
- Sample — capture the voltage at a specific instant (sample-and-hold)
- Quantize — snap to the nearest discrete level (introduces ±½ LSB error)
- Encode — represent the level as a binary number
The RP2350's 12-bit SAR ADC does all three in ~2 us, 500,000 times per second.
Quick Check 2
Why must you sample at least 2x the signal frequency?
Quick Check 2 -- Answer
Nyquist criterion — to prevent aliasing.
If you sample slower than 2x the signal frequency, the sampled data looks like a different, slower signal (alias). This is not recoverable — you cannot tell from the data which signal was real.
Practical rule: sample at 5--10x for safety margin.
Quick Check 3
Your robot's OLED and IMU both use I2C. How does the Pico know which device to talk to?
Quick Check 3 -- Answer
Each device has a unique I2C address.
- OLED: 0x3C (decimal 60)
- IMU: 0x69 (decimal 105)
The master (Pico) sends the device address at the start of every transaction. Only the addressed device responds with an ACK. All other devices ignore the message.
i2c.scan() discovers which addresses are present on the bus.
Quick Check 4
A GPIO pin outputs 12 mA. A motor needs 500 mA. What sits between them?
Quick Check 4 -- Answer
An H-bridge (AM1016A on your robot).
The GPIO pin provides the signal — direction and PWM duty cycle. The H-bridge provides the power — switching battery current through the motor at the commanded duty cycle.
This signal-vs-power separation is a fundamental pattern: the MCU decides what to do, a power stage delivers the energy to do it.
Quick Check 5
Name the three types of sensor error and one fix for each.
Quick Check 5 -- Answer
| Error Type | Fix |
|---|---|
| Systematic (consistent bias) | Calibration — measure the offset and subtract it |
| Random (noise) | Averaging / filtering — smooth out the fluctuations |
| Gross (obviously wrong) | Bounds checking — reject readings outside valid range |
Neither calibration nor filtering alone is enough. You typically need both, plus gross-error rejection for robustness.
Bridge to This Week's Lab
This week (Lab — Ultrasonic & Timing): You will measure distances with the HC-SR04 and discover that blocking kills your loop. You will implement non-blocking timing patterns to keep the robot responsive.
Bridge to Next Labs & Lecture 3
Next week (Lab — Motor Control): You will drive motors with PWM, find the dead zone, and discover that without feedback, the robot cannot drive straight.
Lecture 3 (Week 5): Actuators, Control & IMU Deep dive into DC motor physics, back-EMF, torque-speed curves, open-loop vs closed-loop control, P-control for line following, and IMU integration for precise turns.
End of Lecture 02
Next: Ultrasonic & Timing Lab (this week) | Motor Control Lab (next week)