Learn

Raspberry Pi GPIO with Python, with no Pi on the desk

Beginner

Or see every lesson.

  • 455parts on the bench
  • 43boards running now
  • 1.00xreal time, on every board
Raspberry Pi 4: gpiozero blinks an LED on GPIO17live0.000 s 0.00x
Click to open it in the editor
Press Run. gpiozero turns GPIO17 on and off once a second, and print() shows each change.

Mokxi has the Raspberry Pi 4 Model B, the Raspberry Pi 5 and the Raspberry Pi Zero 2 W on its canvas. Put one down, write main.py with gpiozero or RPi.GPIO, the two libraries every Pi tutorial uses, and press Run. Your program drives the board’s 40-pin header, and the LEDs, buttons, servos and sensors wired to it behave as they would on the bench.

There is no Linux in the page. Your Python runs in a small Python engine (MicroPython, built for the browser), with gpiozero 2.0.1 and RPi.GPIO 0.7.1 written to behave like the real libraries: the same classes and arguments, the same warnings, the same errors. time.sleep(1) is one simulated second, so what a program measures here is what it measures on a Pi.

The circuit above is the first program on any Pi: an LED on GPIO17, header pin 11, through a 330 ohm resistor. Press Run and it is on for a second, off for a second, and the Serial Monitor shows each change. Measured on the pin, each half of the cycle is 1000 ms to within 0.2 ms.

A button that toggles an LED

gpiozero’s Button calls a function of yours when it is pressed, and signal.pause() keeps the program waiting for it. The button here goes from GPIO2 to ground. GPIO2 has the board’s own 1.8 k pull-up, so it reads high until the button is pressed.

A real button bounces: its contacts open and close for a few milliseconds before they settle, and the simulated one does too. With bounce_time=0.05 gpiozero reports a change only once the pin has held it for 50 ms, so each press toggles the LED once. Leave it out and one press can toggle it more than once, as on a real Pi.

main.py: a button toggles an LED
# A button toggles an LED, with gpiozero.
# The button is between GPIO2 (pin 3) and ground: GPIO2 has the board's own
# 1.8 k pull-up, and Button() turns the pad's pull-up on as well.
from gpiozero import Button, LED
from signal import pause

led = LED(17)
button = Button(2, bounce_time=0.05)   # ignore 50 ms of contact bounce

def toggle():
    led.toggle()
    print("LED is", "on" if led.is_lit else "off")

button.when_pressed = toggle
pause()   # wait for presses for ever; Ctrl-C in the Serial Monitor stops it

A servo on RPi.GPIO’s PWM

A hobby servo wants a pulse every 20 ms, 50 Hz, and the pulse’s length sets the angle. GPIO.PWM(18, 50) makes that pulse on GPIO18, and ChangeDutyCycle() sets its length as a percentage of the 20 ms. This servo reads 0.544 ms as 0 degrees and 2.4 ms as 180, so 0, 45, 90, 135 and 180 degrees are 2.72%, 5.04%, 7.36%, 9.68% and 12.00% duty, and the servo on the canvas moves to each angle in turn.

On a real Pi, RPi.GPIO’s PWM is software, and a busy Linux makes the pulses jitter, so a servo twitches. Here the pulses are exact. gpiozero’s AngularServo does the arithmetic for you; on a Pi 5 it sets the duty in whole percent, so a 50 Hz pulse moves in 10 µs steps, as it does there.

main.py: sweep a servo with RPi.GPIO
# Sweep a servo with RPi.GPIO's software PWM on a Raspberry Pi 4.
# Signal on GPIO18 (pin 12), power from 5V (pin 2), ground on pin 6.
import RPi.GPIO as GPIO
import time

SERVO = 18
GPIO.setmode(GPIO.BCM)
GPIO.setup(SERVO, GPIO.OUT)
pwm = GPIO.PWM(SERVO, 50)        # 50 Hz: one pulse every 20 ms

def angle_to_duty(angle):
    # This servo reads a 0.544 ms pulse as 0 degrees and 2.4 ms as 180.
    pulse_ms = 0.544 + angle / 180 * 1.856
    return pulse_ms / 20 * 100   # the pulse as a percentage of the 20 ms frame

pwm.start(angle_to_duty(90))
try:
    while True:
        for angle in (0, 45, 90, 135, 180):
            pwm.ChangeDutyCycle(angle_to_duty(angle))
            print(f"{angle} degrees: {angle_to_duty(angle):.2f}% duty")
            time.sleep(1)
except KeyboardInterrupt:
    pwm.stop()
    GPIO.cleanup()

An HC-SR04 distance sensor on a Pi 5

gpiozero’s DistanceSensor sends the HC-SR04 a trigger pulse and times the echo. The echo is a 5 V signal, more than a Pi’s 3.3 V pin can take, so it comes through a 1 k and 2 k divider: 5 V times 2/3 is 3.3 V. Leave the divider out and the Circuit Doctor says so.

Set the sensor to 100 cm and the program prints 100.0 cm, to within 0.5 cm; at 15 cm it prints 15.0 cm and the LED on GPIO17 lights, because threshold_distance=0.2 makes 20 cm the edge of “in range”.

main.py: read an HC-SR04 with gpiozero
# An HC-SR04 with gpiozero on a Raspberry Pi 5.
# TRIG on GPIO23 (pin 16). ECHO is a 5 V signal, so it reaches GPIO24 (pin 18)
# through a 1 k and 2 k divider: 5 V x 2/3 is 3.3 V, all a Pi's pin can take.
from gpiozero import DistanceSensor, LED
from time import sleep

sensor = DistanceSensor(echo=24, trigger=23, max_distance=2, threshold_distance=0.2)
led = LED(17)

sensor.when_in_range = led.on       # closer than 20 cm: the LED lights
sensor.when_out_of_range = led.off

while True:
    print(f"{sensor.distance * 100:.1f} cm")
    sleep(0.5)

BCM or BOARD: two ways to number the pins

RPi.GPIO needs to be told how you count. GPIO.setmode(GPIO.BCM) uses the chip’s GPIO numbers, which gpiozero always uses: GPIO17. GPIO.setmode(GPIO.BOARD) uses the pin’s place on the header, counting from pin 1 at the corner: pin 11. They are the same pin. The program below lights header pins 11 and 13 in turn, half a second each, and they are GPIO17 and GPIO27.

Ask for a pin that is not a GPIO in the current numbering, such as BOARD pin 2, which is 5V, and RPi.GPIO stops with “The channel sent is invalid on a Raspberry Pi”, as it does on a real one. Set up a pin twice without GPIO.cleanup() and it warns that the channel is already in use, with the line.

main.py: RPi.GPIO with BOARD numbering
# RPi.GPIO with BOARD numbering: pins by their place on the header.
# Header pin 11 is GPIO17 and pin 13 is GPIO27. With GPIO.setmode(GPIO.BCM)
# the same two LEDs would be 17 and 27.
import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BOARD)
LEDS = [11, 13]
GPIO.setup(LEDS, GPIO.OUT, initial=GPIO.LOW)

try:
    while True:
        for pin in LEDS:
            GPIO.output(pin, GPIO.HIGH)
            print("header pin", pin, "on")
            time.sleep(0.5)
            GPIO.output(pin, GPIO.LOW)
finally:
    GPIO.cleanup()

An OLED on I2C with smbus2

The header’s I2C bus is /dev/i2c-1: SDA on GPIO2 (pin 3) and SCL on GPIO3 (pin 5), as on Raspberry Pi OS with I2C turned on in raspi-config. smbus2 and the older smbus talk to it, and the parts on it answer at their addresses: an SSD1306 OLED at 0x3C, a BME280 at 0x76. The program below draws text with a small driver in its own tab, sending the picture in 16-byte blocks the way the classic Pi tutorials do.

The bus runs at Raspberry Pi OS’s default 100 kHz on the simulated clock, so a 1 KB frame takes as long as it does on a desk: the program prints 100 to 125 ms a frame. Ask for an address nobody answers and smbus raises OSError: [Errno 121] Remote I/O error, the error a loose wire gives on a real Pi.

main.py: text on an SSD1306 with smbus2
# An SSD1306 OLED on a Raspberry Pi 4, over smbus2.
# SDA to GPIO2 (pin 3), SCL to GPIO3 (pin 5), VCC to 3V3 (pin 1), GND (pin 9).
# ssd1306_smbus.py is a small driver in its own tab.
import time
from ssd1306_smbus import SSD1306

oled = SSD1306(bus=1, address=0x3C)   # /dev/i2c-1, the header's I2C bus
start = time.monotonic()

while True:
    seconds = int(time.monotonic() - start)
    oled.fill(0)
    oled.text("Hello, Pi!", 0, 0)
    oled.text("smbus2 on i2c-1", 0, 16)
    oled.text("up %d s" % seconds, 0, 40)
    t = time.monotonic()
    oled.show()                       # 1 KB at 100 kHz: about a tenth of a second
    print("frame", seconds, "took %.0f ms" % ((time.monotonic() - t) * 1000))
    time.sleep(1)

A potentiometer through an MCP3008

A Pi has no analog inputs, so a knob or a light sensor goes through an analog-to-digital converter, and the MCP3008 is the one gpiozero’s documentation uses. It sits on SPI0: CLK to GPIO11, DOUT to GPIO9, DIN to GPIO10 and CS to GPIO8, which is /dev/spidev0.0. MCP3008(channel=0) reads it through spidev, and led.source = pot hands the reading to a PWMLED.

The chip’s code is 1024 times the input over its reference, so a knob turned to 800 of 1023 on 3.3 V reads 800, and gpiozero’s value is 0.782, scaled as gpiozero 2.0.1 scales it. spidev works on its own too: spi.xfer2([1, 0x80, 0]) is the same reading by hand.

main.py: an MCP3008 with gpiozero
# A potentiometer through an MCP3008 on a Raspberry Pi 5, with gpiozero.
# The Pi has no analog inputs, so the MCP3008 converts: CLK to GPIO11 (pin 23),
# DOUT to GPIO9 (pin 21), DIN to GPIO10 (pin 19), CS to GPIO8 (pin 24, CE0).
from gpiozero import MCP3008, PWMLED
from time import sleep

pot = MCP3008(channel=0)    # spidev0.0: SPI0 with chip select CE0
led = PWMLED(18)

led.source = pot            # the knob sets the LED's brightness

while True:
    print("value %.3f  raw %4d  %.2f V" % (pot.value, pot.raw_value, pot.voltage))
    sleep(0.5)

RPi.GPIO on a Raspberry Pi 5

RPi.GPIO does not work on a Pi 5. Its pins are on a new chip, the RP1, where RPi.GPIO cannot find them, and the first GPIO.setup() stops with RuntimeError: Cannot determine SOC peripheral base address. Mokxi’s Pi 5 does the same, and the editor says to use gpiozero, which is what the Raspberry Pi documentation uses and which works on all three boards.

What is simulated, and what is not

Simulated: the 40-pin header of each board, with its 3.3 V and 5 V pins and its grounds, GPIO 0 to 27 as inputs with their pull-ups and pull-downs or as 3.3 V outputs, PWM, edge callbacks with their debounce, I2C and SPI through smbus, smbus2, spidev and gpiozero’s SPI devices, and Python’s time on the simulated clock. print() and input() use the Serial Monitor, and Ctrl-C stops the program with KeyboardInterrupt.

Not simulated: Linux itself, so there is no desktop, no terminal and no sudo; pip and apt, so only the modules that come with the board can be imported; networking of any kind; the camera, the display and the audio; threads; and the I2C bus’s speed setting and clock stretching, so I2C runs at the default 100 kHz. The engine is MicroPython, not CPython, so a few things in the language differ: isinstance(True, int) is False, for one. A program that drives the pins runs unchanged.

Questions

Is this a full Raspberry Pi emulator?

No. There is no Linux and no Arm processor in the page. Your Python program runs in a Python engine in the browser, and its gpiozero and RPi.GPIO drive the board’s 40-pin header. Everything a circuit can see of a Pi is there; the desktop, the shell and the operating system are not.

Which Raspberry Pi boards are there?

The Raspberry Pi 4 Model B, the Raspberry Pi 5 and the Raspberry Pi Zero 2 W, each with the same 40-pin header. RPi.GPIO works on the Pi 4 and the Zero 2 W and fails on the Pi 5, as it does on the real one; gpiozero works on all three.

Do I2C and SPI work?

Yes. `/dev/i2c-1` and `/dev/spidev0.0` and `0.1` are there, as on Raspberry Pi OS with both interfaces turned on, and smbus, smbus2, spidev and gpiozero’s MCP3008 talk to the I2C and SPI parts on the canvas at the speed of the bus.

Can I pip install a library?

No. pip and apt are not simulated. Add a library’s .py file as a tab of its own and import it from main.py, which works for libraries written in plain Python.

Does it download anything?

The first Run of a Raspberry Pi project downloads the Python engine, about 150 KB, and the browser keeps it for every later Run. An Arduino project never downloads it.

Build this for real

Open the editor, change a value and watch the number move with it. Nothing to install, and no account needed.