Skip to content

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.

  1. Lab Recap & Bridge
  2. Sensor Interfaces — Where Does Conversion Happen?
  3. ADC Deep Dive
  4. Communication — I2C & the OLED
  5. PWM & Motor Basics
  6. Ultrasonic Ranging
  7. Calibration & Error
  8. 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:

bg right:50% w:90%

  • More light → lower resistance
  • Less light → higher resistance
  • Non-linear characteristic

The Light Sensor — Characteristic

bg right:50% w:90%

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:

bg right:35% w:90%

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

center width:700

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:

center width:550

  • 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

center width:750

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:

  1. Sample — capture the voltage at a specific instant
  2. Quantize — snap it to the nearest discrete level
  3. 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.

center width:700

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.

center width:700

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.

center width:600


Step 3 — Encoding

Convert the quantized level to a binary number.

  • 12-bit ADC → 4096 levels → binary numbers 0b000000000000 to 0b111111111111
  • Hex: 0x000 to 0xFFF
  • 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.

12-bit ADC value:  2048  (0x800)
read_u16() returns: 32768 (0x8000)  — same information, wider range

Resolution: How Many Levels?

With n bits, the voltage resolution (smallest detectable change) is:

\[ \text{Voltage Resolution} = \frac{V_{\text{ref}}}{2^n} = \frac{3.3\text{V}}{4096} \approx 0.8\text{ mV} \]
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):

\[ \text{ADC Value} = \frac{V_{\text{in}}}{V_{\text{ref}}} \times (2^n - 1) = \frac{1.65}{3.3} \times 4095 = 2047 \]

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:

\[ V_{\text{in}} = \frac{3000}{4095} \times 3.3\text{V} \approx 2.42\text{V} \]

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:

center width:700

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}\):

center width:700

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:

center width:700

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.

center width:700

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.

center width:700

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

center width:700

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:

\[f_s > 2 \times f_{\max}\]

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

from machine import ADC, Pin

sensor = ADC(Pin(26))
raw = sensor.read_u16()  # Returns 0 - 65535

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

center width:700

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.


center width:700

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.

center width:700


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.

center width:600

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


Buzzer Drive Circuit

bg right:35% w:90%

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:

bg right:45% w:90%

  • 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

center width:700

Same 50% duty cycle, very different loudness — because the disc physically can't keep up at high frequency.


Buzzer: Frequency vs Duty Cycle

center width:650

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.

center width:600


I2C Protocol Basics

The master controls the clock and initiates every transaction:

STARTAddress (7 bits) → R/W bitACKData bytesSTOP

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
  1. Draw in RAMoled.text("Hello", 0, 0) writes pixels to a 512-byte array. Instant.
  2. Send over I2Coled.show() sends all 512 bytes + protocol overhead. At 400 kHz, this takes ~10 ms.
  3. 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.

Sensor:        Line (GPIO)    Light (ADC)    Ultrasonic (timing)
Read time:     ~1 us          ~2 us          ~23,000 us  ← problem!

Ultrasonic Ranging: Time of Flight

bg right:50%

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:

\[d = \frac{v \cdot t}{2}\]
  • 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

center width:700

Three signals, strict sequence:

  1. Trigger — MCU sends a 10 us HIGH pulse
  2. Burst — sensor emits 8 cycles of 40 kHz ultrasound
  3. Echo — sensor holds echo pin HIGH proportional to round-trip time
\[\text{distance (cm)} = \frac{\text{echo time (us)}}{58}\]

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 layersrobot.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.

  1. Sample — capture the voltage at a specific instant (sample-and-hold)
  2. Quantize — snap to the nearest discrete level (introduces ±½ LSB error)
  3. 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)