Skip to content

Pixels on a Tiny Screen

Time: 45 min

Learning Objectives

By the end of this lab you will be able to:

  • Initialize and control an SSD1306 OLED display over I2C
  • Display text and draw individual pixels on the screen
  • Combine sensor readings with visual output for real-time display
  • Build a simple battery monitor with a graphical icon

You will move from print() debugging to visual output — displaying sensor data directly on the robot's screen.

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

So far, you've been reading sensor values in the serial terminal — squinting at scrolling numbers on your laptop. What if the robot could show data on its own screen?

Your robot has a tiny OLED display — 128×32 monochrome pixels controlled by an SSD1306 chip. It connects over I2C, a simple 2-wire bus. In this lab, you'll learn to display text, draw pixels, and build a real-time sensor dashboard.


Why This Lab Matters

Every embedded system needs some form of output for debugging and status display. In later labs, you'll use the OLED to show robot state (which state machine state you're in), sensor readings during tuning, and error messages when things go wrong. Learning to use it now gives you a powerful debugging tool for the rest of the course.


Part 1: Hello OLED (10 min)

Setting Up the Display

The OLED connects via I2C bus 1:

Signal Pin Purpose
SCL GP15 Clock — synchronizes data transfer
SDA GP14 Data — carries the actual information
from machine import I2C, Pin
from oled import SSD1306

# Initialize I2C bus
i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)

# Initialize display: 128 pixels wide, 32 pixels tall
oled = SSD1306(i2c)
I2C in 30 Seconds

I2C (Inter-Integrated Circuit) is a 2-wire communication bus:

  • SCL (clock): the master (Pico) toggles this to synchronize transfers
  • SDA (data): carries data bits, synchronized to the clock

Multiple devices can share the same 2 wires — each has a unique address. The SSD1306 display uses address 0x3C. You won't need to worry about the protocol details; the library handles everything.

⚡Hands-on tasks

✅ Task 1 — Display "Hello World!"

from machine import I2C, Pin
from oled import SSD1306

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

# Clear the screen (fill with black)
oled.fill(0)

# Draw text at position (x=0, y=12)
oled.text("Hello World!", 0, 12)

# Push the buffer to the display
oled.show()

Three steps, every time: fill (clear) → draw (text, pixels, shapes) → show (send to display).

✅ Task 2 — Who's on the Bus?

Before moving on, let's see what the I2C bus actually looks like. The i2c object you created can scan the bus and report every device it finds:

from machine import I2C, Pin

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)

devices = i2c.scan()
print(f"Found {len(devices)} device(s):")
for addr in devices:
    print(f"  0x{addr:02X} ({addr})")

You should see at least 0x3C — that's the OLED display. You might also see 0x68 or 0x69 — that's the BMI160 IMU (which you'll use in later labs).

How I2C Scanning Works

The Pico sends a "hello, are you there?" message to every possible address (0x00–0x77). If a device responds with an acknowledgment (ACK), it's on the bus. This is how you debug I2C wiring problems — if scan() returns an empty list, nothing is connected (or your wires are wrong).

Each device has a fixed address set by its manufacturer. This is why you can have an OLED (0x3C) and an IMU (0x68) on the same two wires — they never confuse each other's messages.

Try this: Disconnect the OLED ribbon cable (carefully!) and scan again. The address disappears. Reconnect and scan — it's back. This is a quick way to verify wiring.

✅ Task 3 — Blinking Pixel

from machine import I2C, Pin
from oled import SSD1306
import time

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

while True:
    # Draw a white pixel at (64, 16) — center of screen
    oled.fill(0)
    oled.pixel(64, 16, 1)
    oled.show()
    time.sleep(0.5)

    # Clear it
    oled.fill(0)
    oled.show()
    time.sleep(0.5)
Checkpoint — OLED Working

You can display text and individual pixels on the OLED. The pattern is always: fill() → draw → show().


Part 2: Drawing on the Screen (15 min)

Coordinate System

The OLED uses a standard screen coordinate system:

(0,0)─────────────────────(127,0)
  │                            │
  │      128 × 32 pixels       │
  │                            │
(0,31)────────────────────(127,31)
  • (0, 0) is the top-left corner
  • x increases to the right (0–127)
  • y increases downward (0–31)
Text Size

oled.text() uses a built-in 8×8 pixel font. On a 128×32 display, that means:

  • 16 characters per line (128 ÷ 8)
  • 4 lines of text (32 ÷ 8), at y positions 0, 8, 16, 24

⚡Hands-on tasks

✅ Task 4 — Underline Text

Draw "Hello" with a horizontal line underneath using individual pixels:

from machine import I2C, Pin
from oled import SSD1306

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

oled.fill(0)
oled.text("Hello", 0, 4)

# Draw horizontal line at y=14 (just below the text)
for x in range(40):  # 5 chars × 8 pixels = 40 pixels wide
    oled.pixel(x, 14, 1)

oled.show()

Experiment: Try drawing a rectangle around the text using four loops (top, bottom, left, right edges).

✅ Task 5 — Display Core Temperature

Combine the ADC reading from Reading the Analog World with the OLED display:

from machine import I2C, Pin, ADC
from oled import SSD1306
import time

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

temp_adc = ADC(ADC.CORE_TEMP)

while True:
    # Read temperature
    raw = temp_adc.read_u16()
    voltage = (raw / 65535) * 3.3
    temp = 27 - (voltage - 0.706) / 0.001721

    # Display on OLED
    oled.fill(0)
    oled.text("Core Temp:", 0, 0)
    oled.text(f"{temp:.1f} C", 0, 16)
    oled.show()

    time.sleep(0.5)

The display now shows live temperature data — no serial terminal needed!

Checkpoint — Sensor + Display

You can combine sensor readings with visual output. This is the foundation of every embedded dashboard.


Part 3: Battery Monitor with Icon (20 min)

⚡Hands-on tasks

✅ Task 6 — Battery Voltage on Screen

Display the battery voltage and estimated state of charge:

from machine import I2C, Pin, ADC
from oled import SSD1306
import time

i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

battery_adc = ADC(Pin(28))

while True:
    # Read battery voltage (through voltage divider)
    raw = battery_adc.read_u16()
    v_battery = (raw / 65535) * 3.3 * 3

    # State of charge
    soc = ((v_battery - 3.0) / (4.2 - 3.0)) * 100
    soc = max(0, min(100, soc))

    # Display
    oled.fill(0)
    oled.text("Battery:", 0, 0)
    oled.text(f"{v_battery:.2f}V  {soc:.0f}%", 0, 16)
    oled.show()

    time.sleep(1)

✅ Task 7 (Challenge) — Battery Icon

Draw a graphical battery icon that fills proportionally to the state of charge:

┌──────────┐╥
│██████░░░░│║   ← fill proportional to SoC
└──────────┘╨
Hint: Battery Icon Function
def draw_battery(oled, x, y, soc):
    """Draw a battery icon at (x, y) with fill based on SoC (0-100)."""
    # Battery outline: 30×12 pixels
    w, h = 30, 12

    # Top and bottom edges
    for i in range(w):
        oled.pixel(x + i, y, 1)
        oled.pixel(x + i, y + h - 1, 1)

    # Left edge
    for j in range(h):
        oled.pixel(x, y + j, 1)

    # Right edge
    for j in range(h):
        oled.pixel(x + w - 1, y + j, 1)

    # Battery tip (positive terminal)
    for j in range(3, h - 3):
        oled.pixel(x + w, y + j, 1)
        oled.pixel(x + w + 1, y + j, 1)

    # Fill proportional to SoC (inside the outline)
    fill_width = int((w - 4) * soc / 100)
    for i in range(fill_width):
        for j in range(2, h - 2):
            oled.pixel(x + 2 + i, y + j, 1)

Use it like this:

oled.fill(0)
oled.text(f"{soc:.0f}%", 0, 0)
draw_battery(oled, 48, 10, soc)
oled.show()

Try this: Toggle the power switch or connect/disconnect the USB charger while watching the display update in real time.

Checkpoint — Real-Time Sensor Display

You can build a real-time sensor display with graphical elements — the foundation of any embedded UI. In later labs, the OLED will be invaluable for debugging state machines and displaying robot status during autonomous operation.


What You Discovered

Key Concepts

Concept What You Learned
I2C 2-wire bus (SCL + SDA) for communicating with peripherals
SSD1306 OLED display controller, 128×32 pixels, address 0x3C
Display pattern fill(0) → draw text/pixels → show()
Coordinate system (0,0) = top-left, x→right, y→down
Text limits 8×8 font → 16 chars/line, 4 lines on 128×32
Sensor + display Combine ADC readings with OLED output for standalone dashboards

The Display Pattern

Every OLED update follows the same three steps:

oled.fill(0)              # 1. Clear the buffer
oled.text("data", 0, 0)   # 2. Draw into the buffer
oled.show()                # 3. Send buffer to display

If you skip fill(0), old content remains on screen. If you skip show(), nothing appears.


What's Next?

In Ultrasonic & Timing, you'll discover a critical embedded challenge: timing. What happens when code takes too long? You'll learn blocking vs non-blocking patterns that appear in every real-time system.


Challenges (Optional)

These combine everything from Lab 2 — GPIO, ADC, and OLED. Each challenge is self-contained.

Useful references: OLED Display Reference · ADC Basics · Battery Voltage · picobot Library · Key Code Reference

✅ Challenge 1 — Full Sensor Dashboard

Display temperature, light, and battery on the OLED simultaneously — use all four text lines (y = 0, 8, 16, 24).

Hints
  • You need three ADC channels (core temp, GP26, GP28) plus the OLED — reuse the setup from the tasks above
  • The display fits 16 characters per line and 4 lines total — plan your layout
  • Keep each label short: "Temp:", "Light:", "Batt:", "SoC:"
  • Extension: add a small pixel bar graph next to the light percentage (like the battery icon approach)

✅ Challenge 2 — Light Level Bar Graph

Draw a horizontal bar that grows and shrinks with light level — a real-time analog meter on screen. See Task 7 for the pixel drawing approach and Light Sensor Reference for calibration.

Hints
  • Convert the light ADC value to a percentage (0–100%), then scale to pixel width (max ~120px)
  • oled.rect(x, y, w, h, 1) draws an outline rectangle — use it for the bar border
  • oled.fill_rect(x, y, w, h, 1) draws a filled rectangle — use it for the filled portion
  • Show the percentage as text on line 1, draw the bar below it

Try it: Wave your hand over the sensor and watch the bar respond. How fast can it track your hand?

✅ Challenge 3 — Line Sensor Visualizer

Show the 4 line sensors on the OLED as filled/empty boxes — a heads-up display for the robot. See GPIO & Sensors — Task 4 for read_raw() and Optocoupler Reference for sensor behavior.

Hints
  • Read line sensors with robot.sensors.line.read_raw() → returns [0, 1, 1, 0]
  • Use a for loop with enumerate() to draw 4 boxes spaced across the screen
  • Black detected (val == 1) → oled.fill_rect() (filled box)
  • White (val == 0) → oled.rect() (empty outline)
  • Add a title on line 1: oled.text("Line Sensors", 16, 0)

Slide the robot over tape and watch the boxes fill in real time. This is a preview of how you'll debug the line follower later.

✅ Challenge 4 — Scrolling Graph

Record light sensor readings over time and draw a scrolling graph — like a tiny oscilloscope. See Analog Sensors — Task 4 for light calibration and OLED Display Reference for pixel drawing.

Hints
  • Keep a list of 128 values (one per pixel column) — this is your history buffer
  • Each iteration: remove the oldest value (pop(0)), append the new one
  • Scale the percentage (0–100) to screen height (0–31 pixels)
  • Draw each column as a single pixel: oled.pixel(x, 31 - value, 1) — flip Y so higher values go up
  • The screen is 128 pixels wide, so you get ~6 seconds of history at 50ms per sample

Try it: Wave your hand over the sensor rhythmically — you should see a wave pattern scroll across the screen.

Think about it

The graph updates at ~20 fps (50ms sleep). What happens if you make it faster? At what point does the OLED's I2C transfer time become the bottleneck? (Hint: sending 128×32 pixels over I2C at 100kHz takes a while.)

✅ Challenge 5 — Startup Self-Test

Write a boot screen that tests each sensor and reports pass/fail on the OLED. See Temperature, Battery Voltage, and Troubleshooting for expected value ranges:

Test Pass Condition
OLED Text appears (if you can read this, it works)
Core temp Reading between 10°C and 60°C
Light sensor Reading between 100 and 64000 (not stuck)
Battery Voltage between 3.0V and 4.5V
Line sensors At least one sensor responds to black/white
Hints
  • Run each test sequentially, store pass/fail in a list
  • Use range checks: 10 < temp < 60, 3.0 < v_bat < 4.5, etc.
  • Print results to serial with a helper function: f" {name:.<20s} [{'OK' if passed else 'FAIL'}]"
  • Show the summary on OLED: f"{passed}/{total} passed"
  • Extension: display each test result on a separate OLED line (you have 4 lines — pick the 4 most important tests)

Key Code Reference

from machine import I2C, Pin
from oled import SSD1306

# Initialize I2C and OLED
i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

# Display text
oled.fill(0)                  # Clear screen
oled.text("Hello", 0, 0)     # Text at (x, y)
oled.show()                   # Push to display

# Draw pixels
oled.pixel(64, 16, 1)        # White pixel at (64, 16)
oled.pixel(64, 16, 0)        # Black pixel (erase)

# Full update loop
while True:
    oled.fill(0)
    oled.text(f"Value: {data}", 0, 0)
    oled.show()
    time.sleep(0.5)

← Analog Sensors | Labs Overview | Next: Ultrasonic & Timing →