Raspberry Pi Pico Workshop for Beginners

Updated 08 April 2024

Welcome to the Raspberry Pi Pico Workshop, where you will learn everything you need to know to hit the ground running and start making your own projects with the Raspberry Pi Pico and MicroPython. This workshop is designed for complete beginners and teaches a wide range of related skills through bite-size videos. My name is Jaryd, I'm an engineer and also a passionate maker who loves to teach and produce educational videos. This course attempts to be one of the most comprehensive Pico courses for beginners, teaching not only fundamental skills in microcontrollers, coding, and electronics but also the wisdom and insights from many of the folks here at Core Electronics who helped create this course.

To follow along, you will need of course a Raspberry Pi Pico, as well as a few other bits and pieces. 

To follow along up to Chapters 2 and 3, you will need to grab a:

All these components can be found here.

As discussed in Chapters 4 and 5, some additional components are needed to follow along with. However, these additional parts can vary depending on your needs and what you wish to do. We have the list of optional parts under those relevant chapters.

Course Outline:

Chapter 1: First Steps

1.1 Getting Started

Although this is a video-based course, for each video we will usually have a short text-based rundown or recap of important topics covered in the video, as well as sample code and links to any additional resources. For example:

In this chapter, we are taking it easy and just covering some important beginner topics including:

  • What is a microcontroller? (spoiler alert, the Pico is a microcontroller!)
  • A walkthrough of the Pico and its Variants
  • How to safely use the Pico
  • Getting a computer ready to program the Pico
  • Some tips on how to get the most out of this course

Let's get started!

1.2 What is a Microcontroller?

In this course, we will be learning how to use the Raspberry Pi Pico, an inexpensive, small, and powerful microcontroller board. Microcontrollers are a kind of small and extremely specialised computer that we can program to complete a specific task, a task that usually involves plugging in hardware like LEDs, buttons, and motors.

If we compared a microcontroller to a regular computer like a desktop or laptop, we would see that a microcontroller is:

  • Smaller and lighter.
  • Slower, but uses MUCH less power.
  • MUCH cheaper.
  • Doesn't use an operating system like Windows or Mac, and instead is pre-programmed for a specific task.

These traits mean that microcontrollers like our Pico are more suitable for applications like in an electric toothbrush. We can use a computer to turn on the vibration motor when we press the button. A full-size computer is most definitely overkill, but a microcontroller is perfect. You will find microcontrollers in everything including; drones, washing machines, printers, fans, microwaves, and alarm clocks, they are in an incredible amount of things.

1.3 The Pico Variants

There are 4 variants of the Raspberry Pi Pico to choose from, and they are all very similar with some slight differences.

  • Pico: This is the regular "base mode" pico. All Pico variants run the same processor, RAM, and memory as this one. The other variants only add things to this model.
  • Pico W: This model comes with wireless capabilities allowing you to connect it to the internet through WiFi, and allows you to use Bluetooth. In nearly every other way it is identical to the regular Pico.
  • Pico H: This board is identical to the regular Pico, it just comes with pin headers already soldered onto the board. You will need these headers, and this is just an option if you don't wish to solder your own.
  • Pico WH: This board is identical to the Pico W, with wireless capabilities and all. It's just an option that comes with the pin headers already soldered to the board, so if you don't wish to solder them yourself, grab the "H" variant of it.

Any Pico can be used for this course as long as you have the headers soldered onto it. Just be aware that in chapter 5 we learn about the wireless capabilities of the Pico, and you will need a W or WH to follow along with.

1.4 Board Walkthrough & Pinout

Here is a handy reference diagram for the Pico Pinout. This is the full diagram that has more labels on each pin than just the GP label (these extra pin labels show what else each pin can be used for, we will explore this later).

Right now, the only thing you need to worry about finding are the light green GP pin labels (GP0 - GP28), VBUS, 3V3OUT, and the ground (GND) pins.

If you want some more technical specs for the Pico, you can find them on the Official Raspberry Pi site, where you can also find some other technical resources.

1.5 Powering The Pico & Safety

It is possible to damage your Pico if you don't handle it properly, and the 3 biggest things ways to prevent this are to:

  • Unplug the Pico's USB cable before connecting anything to it.
  • Double-check that your wiring is correct before plugging the Pico back in.
  • Be careful not to accidentally short its pins together, watch out for metal benches!

If you ever power on a Pico circuit and find that your Pico gets very hot in a matter of seconds (too hot to touch), immediately unplug the USB cable and check your wiring, there is most likely something wrong.

1.6 Thonny, Installing MicroPython & Hello World

We're finally going to get into writing some code for our Pico. We will be using Thonny which is a software program that provides tools for coding, editing, and debugging programs.

You can download Thonny from Thonny.org.

After installing MicroPython you will be able to use our first command of the course, the print function which looks like this:

print("Hello, World!")

This function will print whatever is placed inside the brackets, and to print a word or sentence it needs to be placed inside quotation marks. We will be frequently using this print function as it allows us to print important information to the shell, information like sensor data, whether a button has been pressed and important values.

(Also if you have completed this video, you are now in the loop on a programming tradition.)

1.7 Tips For Success

Here are 5 things to know going into this course to get the most out of it.

  • Understanding is more important than remembering.
  • There is no correct way to do something, some ways may be better, but the best way is the one you are familiar and confident with.
  • Learn at your own pace.
  • Following along with examples is a great way to learn. The best way though is to experiment and play around with the examples.
  • If you get stuck go back to a point it worked (or the beginning) and rework from there, or find an expert to help out.

ChatGPT is a very helpful resource for getting help (even the free version), although you will need to create an account to access it. You can speak to it in plain English, just give it a little bit of context about your issue, and paste in your code if needed.

If all else fails, we have a forum page dedicated to this course where you can ask for help (you can also find it at the very bottom of this page).

Chapter 2: Pico Basics and Basic IO

2.1 Introduction To Basic IO

In this chapter, we will start plugging things into our Pico and writing some code to control them. We will be covering:

  • Digital inputs and outputs
  • Analog inputs and PWM Signals
  • MicroPython Basics
  • Breadboarding and basic circuitry
  • Basic electrical theory

2.2 Digital Outputs & MicroPython Basics 

All 26 GPIO pins on the Pico can act as a digital output (as well as the onboard LED), and we can use this to set the voltage on a pin to be either 0 Volts or 3.3 Volts - this is known as 3.3 Volt logic. The states of these pins can also be called LOW and HIGH, or OFF and ON, or 0 and 1, it all describes whether the signal is 0 or 3.3 volts.

In this first example, we set up the onboard LED as a digital output (setting 3.3 Volts on the LED will turn it on), then using sleeps, we make the LED flash in a pattern. Here is the sample code for that:

from machine import Pin # Import pin from the machine library
import time # Import time library which lets us use the sleep command

led = Pin("LED", Pin.OUT) # Set up the onboard LED (can replace "LED" with a pin GPIO number)

while True:  # Loop forever
    led.value(1)  # Turn the LED ON
    time.sleep(2) # Go to sleep for 2 seconds
    led.value(0)  # Turn the LED OFF
    time.sleep(2) # Go to sleep for 2 seconds

2.3 Introduction To Breadboarding & Circuits

Breadboards provide a quick and easy way to build temporary circuits without the need for soldering. The Pico and other components are placed on the breadboard, and jumper wires are used to connect these components. We will wire up an LED, button and potentiometer, which will be used in the examples for the rest of chapters 2 and 3.

Here is the wiring diagram of that to replicate:

Some important reminders:

  • Always ensure that your Pico is unplugged and your circuit is unpowered before you build or modify it
  • Double-check that your wiring is correct before plugging your Pico in, and that jumper wires are plugged into the right pins
  • Ensure that components are fully inserted into the board, a loose connection is a likely culprit of a circuit that doesn't work

2.4 Reading Digital Inputs

All 26 GPIO pins on the Pico can act as a digital input. When we set up a pin as one, we can think of it acting as a multimeter that will return a value of 0 if the Voltage is close to 0 Volts, and 1 if the Voltage is close to 3.3 Volts. 

In this first example, we set up Pin 15 as a digital input (which has a button connected to it), then simply print the result of reading that input. Here is the code for that:

from machine import Pin
import time

# set up pin 15 as an input with a pull-down resistor
button = Pin(15, Pin.IN, Pin.PULL_DOWN) 

while True: 
    print(button.value()) # read the digital state of the button pin and then print it to shell
    time.sleep(0.1) # without this sleep you may run into issue, it just slows it down a little

We can then combine this with the digital ouput we just learnt to make a simple program that represents the essential function of a micrcontroller. This program takes in an input (the button), does some "thinking" with the if and else functions, then controls and output (an LED) based on that "thinking":

from machine import Pin
import time

# set up pin 15 as an input with a pull-down resistor
button = Pin(15, Pin.IN, Pin.PULL_DOWN)
# set up pin 16 connected to the LED as an output
led = Pin(16, Pin.OUT)

while True:
    # if the button is pressed, then turn on the LED
    if button.value() == 1:
    # if its not pressed, then turn the LED off

2.5 Variables

Variables are one of the most essential tools to learn in coding and nearly every program you write from now on will most likely use them. Variables come in different types and the 3 most common are:

  • Integers which can store whole numbers like 1, 50, and 8342
  • Floats which can store numbers with decimal points like 1.04, 3.14159, and 2451.1024. You can also store whole numbers like integers.
  • Strings which can store words and sentences (which must be in quotation marks) like "Hello, World!" and "This is a test sentence that can be stored in a string"

In this sample code, count how many seconds a button has been pressed. To do so, we create a variable called "timer" and update it depending on the state of the button:

from machine import Pin
import time

# set up pin 15 as an input to read our button's state
button = Pin(15, Pin.IN, Pin.PULL_DOWN)

# intialising a timer variable we will use to keep track of time
timer = 0

while True:
    # this ensures that we check the button every second
    # if the button is pressed, then increase the timer by 1 second
    if button.value() == 1:
        timer = timer + 1
    # print the variable timer to the shell

There are some rules on what you can name a variable though:

  • They must only contain letters, numbers and underscores 
  • They must start with a letter or underscore and NOT a number
  • They cannot be called any of the MicroPython keywords like "import", "while" or "from".

2.6 Reading Analog Inputs

On the Pico, only GPIO pins 26, 27, and 28 can be configured as analog inputs. This is because analog inputs are many times more complex than their digital counterparts and require the use of the Pico's onboard Analog to Digital Converter (ADC).

However, analog inputs are incredibly powerful as they allow your pins to essentially act as a Voltmeter and read the exact voltage on the pin as long as it is between 0 and 3.3 Volts. This isn't what an analog input in MicroPython reads at natively though as it will read that voltage range as a number between 0 and 65,535.

That's why in this sample code where we read and print the voltage on pin 26 (connected to a potentiometer), we use the conversion_factor variable to turn it into a nice readable 0 to 3.3 Volts:

from machine import Pin, ADC # we need to import ADC to use analog inputs
import time

# set up pin 26 as an analog input
pot = ADC(Pin(26)) 

# constant to convert the 0 - 65535 range to 0 - 3.3 volts
conversion_factor = 3.3 / 65535

while True:
    # read analog input and store it in a variable called pot_voltage
    pot_voltage = pot.read_u16()
    # print pot_voltage

2.7 Writing PWM Outputs

Like the overwhelming majority of computers, the Pico is a digital device (a device that deals with 1s and 0s) and while they have little issues with digital signals, they often struggle with analog signals. The Pico requires an ADC just to read analog signals, but like nearly all microcontrollers, it isn't capable of producing a true analog output.

This is where PWM comes to the rescue, by turning the pin off and on extremely fast (thousands to millions of times a second), we can create an average voltage on the pin. The percentage of time the pin is on is called the duty cycle. 50% duty cycle would give 50% of 3.3 Volts, 20% would give 20% of 3.3 Volts and so on. An incredible thing about the Pico is that all 26 GPIO pins can be used to create a PWM signal - with many microcontrollers, you would be lucky to get more than 10. 

In this example, we have the simplest implementation and just set a PWM on pin 16 to control the brightness of the LED:

from machine import Pin, PWM # we need to import PWM to use PWM
# intialise pin 16 as a PWM output
pwm_pin = PWM(Pin(16))
# this sets up the frequency that the pin is turned off and on (it is not duty cycle)

# this varaible is used to help calculate the required input from a duty cycle percentage
max = 65535

while True:
    # here we multiply max by the desired duty cycle (as a decimal), 0.2 is 20%, 0.7 is 70% and so on
    PWM_value = int(0.5 * max)
    # this line is the command that acutally sets the duty cycle

Then we take it up a notch and read the potentiometer with an analog input, and then use that input to control the brightness of the LED in realtime:

from machine import Pin, ADC, PWM
import time
# set up pin 26 as an analog input, and pin 16 as PWM output
pot_pin = ADC(Pin(26)) 
pwm_pin = PWM(Pin(16))
# set the pin frequency (this is not duty cycle)

while True:
    # read the potentiometer value (0 to 65,535) and store it in a variable
    pot_value = pot_pin.read_u16() 
    # set the reading as a PWM duty cycle
    # uncomment the line below to print voltage instead of the RAW ADC value.
    #pot_value = pot_value * 3.3 / 65535

2.8 Importing Libraries

Libraries are pre-written chunks of code that allow us to achieve a lot with only a few lines of code. For example, when we set up a pin as an analog input, there are quite a lot of things that need to happen to initialise the pin, turn on the ADC, and get the ADC to work with the pin. Thankfully MicroPython comes with the machine library which lets us do all this with a single line of code.

MicroPython comes by default with the machine library, and this is called a "standard library". But there are also external libraries that we can use to achieve specific tasks. One of these examples is the servo library which easily allows us to control a servo by directly specifying an angle (instead of a PWM and having to set the right frequency.)

If you want to explore libraries more or want to make your code experience a little easier, check out the Pico Zero Library, it's a beginner-friendly library made by Raspberry Pi themselves and simplifies the process of using a lot of basic components.

2.9 Running a Pico Without a Computer

It's good and all to run the Pico off a computer, but most projects will probably need to run without it. To achieve this we will need to do 2 things: run the code off our Pico instead of the computer, and power the Pico from another power source.

Running the code off of the Pico is easy enough, save the script you want to run as "main.py" and the Pico will run that file whenever it is powered on.

There are many ways to power the Pico. You can power it through USB (which may be the easiest) using a wall phone charger, battery bank, or AA battery to a micro USB box.

You can also power it through the Vsys pin by supplying 1.8 to 5.5 volts to it. This could come in the form of a small solar panel, some AA batteries, or a LiPo battery.

IMPORTANT: You cannot power the Pico through Vsys and micro USB at the same time, this will most likely damage the Pico and your power source. If you are using Vsys to power your Pico, it is recommended to use a Diode. This only allows electricity to flow in 1 direction, meaning you can safely power your Pico with Vsys and micro USB. BUT ONLY IF YOU HAVE THE DIODE INSTALLED.

2.10 Sourcing Power from the Pico

We arrive at the final video to wrap off chapter 2. This is a mightily practical one as powering components off of your Pico is vitally important as it is very easy to damage something. There are 3 main power sources from the Pico to use:

  1. 3V3(OUT): This supplies 3 Volts and up to 300 Milliamps of current. 
  2. Vbus: This supplies 5 Volts and up to 2000 Milliamps of current. The current however is dependent on the output capabilities of the USB you are powering it with. If you are using a USB power source that can only supply 1000 Milliamps, you can only use 1000 Milliamps.
  3. Gpio Pins: These can supply 0 to 3.3 Volts and up to 12 Milliamps of current. You shouldn't try to power many things off the pins besides passive components that need to (such as LEDs and Piezo buzzers).

If you have a device you wish to power, you must match the voltage, and ensure that the power source can supply enough current. For example, a small 9g servo needs 5 Volts and up to 600 Milliamps of power. Vbus matches the voltage of 5 Volts and can supply more than the 600 Milliamps required

Wanna take your power to the next level or interested in using a motor driver? We have a great guide on how to do so.

Chapter 3: Logic and Decision-Making

3.1 Introduction to Logic and Decision Making

In this chapter, we will start adding some "intelligence" to our scripts by learning how to use:

  • Boolean logic and operators
  • If, else and elif
  • For and while loops
  • Functions

These are powerful tools that we can use to control the flow of our code, for example, we can choose when to run (or not to run) sections of our code or we can keep repeating code till a certain condition is met. With these basic tools, we can build up more and more complex decision-making and logic to give our scripts a sense of "intelligence".

3.2 Boolean Logic & Comparative Operators

Making a decision with code can be boiled down into 2 steps. The first is to make a statement that compares data, and the second is to choose a course of action based on that comparison. This video will cover the first step of that process and later we will be using these statements to make decisions upon. When we construct a statement, the result of it can only be either true or false. This true or false outcome is known as boolean logic.

In constructing statements, we can compare variables, numbers, and data with comparative operators and thankfully there are only 6 that we need to learn:

  • Equals to ( == )
  • Not equals to ( != )
  • Greater than ( > )
  • Less than ( < )
  • Greater or equal to ( >= )
  • Less than or equal to ( <= )

We can use these to construct statements to compare different pieces of data. For example, we could check if x is equal to y, or if a temperature reading is greater than 40, and these will return true or false boolean logic.

We can also construct statements using boolean operators which allow us to compare comparisons, and there are only 3 that we need to learn:

  • and - which returns true if statements A and B are true.
  • or - which returns true if statement A or B is true.
  • not -  which can be used to inverse the logic of a statement (makes a true statement return false, and vice versa)

3.3 If, Else, & Elif

Now that we know how to make a statement, lets learn how to execute code based on the result of that statement. 3 powerful tools to do this are the if, else, and elif (else if) keywords. 

  • If - we give it a statement and a section of code, if the statement is true the code will be executed.
  • Else - we place it after an if or elif and give it a section of code, if the if before it is false then the code we place inside of the else will be executed.
  • Elif - this behaves like an if check, but the elif will only check its statement when the if or elif before it is false. It is kind of like saying "if the previous if wasn't true, give this one ago.

We can use these keywords to control the flow of our code and make decisions based on data or inputs. In this video's example we use if, elif and else to check the input from a potentiometer and produce a volume output based on it.

from machine import Pin, ADC
import time

pot = ADC(Pin(26)) 

# set up conversion constant and start a last volume variable at 0
last_volume = 0
const = 100 / 65535

change this string to choose which method is used.
a uses if, else and elif in an inefficient method.
b uses a more efficient method.
method = "b"

if method == "a":

    while True:
        # Read the value from the potentiometer
        current_volume = int(pot.read_u16() * const)

        # Check if the volume level has changed enough to print new line
        if ((current_volume - last_volume) >= 2) or ((current_volume - last_volume) <= -2):
            # use if, elif, else logic to print a volume bar based on the current volume
            if 0 <= current_volume < 10:
                print("__________", current_volume)

            elif 10 <= current_volume < 20:
                print("█_________", current_volume)

            elif 20 <= current_volume < 30:
                print("██________", current_volume)

            elif 30 <= current_volume < 40:
                print("███_______", current_volume)

            elif 40 <= current_volume < 50:
                print("████______", current_volume)

            elif 50 <= current_volume < 60:
                print("█████_____", current_volume)

            elif 60 <= current_volume < 70:
                print("██████____", current_volume)

            elif 70 <= current_volume < 80:
                print("███████___", current_volume)

            elif 80 <= current_volume < 90:
                print("████████__", current_volume)

            elif 90 <= current_volume < 100:
                print("█████████_", current_volume)
                print("██████████", 100)                
            # Update last volume
            last_volume = current_volume

        time.sleep(0.05)  # Small delay to prevent excessive reading

if method == "b":

    while True:
        # Read the value from the potentiometer
        current_volume = int(pot.read_u16() * const)

        # Check if the volume level has changed enough to move to a new 10% increment
        if ((current_volume - last_volume) >= 2) or ((current_volume - last_volume) <= -2):
            # This is a different way to create the volume bar based on volume
            num_bars = int(current_volume / 10)
            print("█" * num_bars + "_" * (10 - num_bars), current_volume)
            # update last volume
            last_volume = current_volume

        time.sleep(0.05)  # Small delay to prevent excessive reading

3.4 For Loops & Lists

For loops are one of the two types of loops we can use in MicroPython (and most coding languages as well). For loops are designed to be looped a set and known amount of times, and they will execute the code placed inside of them on every loop. 

In this example, we use for loops to create a servo sweep with the servo library (the same one we used in chapter 2.8). This script slowly rotates the servo in a smooth motion from 0 to 180 degrees then back. Like in this example, the range function is often used with for loops to set the number of times it is looped.

from machine import Pin, PWM
from time import sleep
from servo import Servo

# Initialize Servo on pin 0 with micropythonservo library
servo = Servo(pin_id=0)

# Sweep the servo from 0 to 180 degrees and back
while True:
    for angle in range(0, 180, 5):  # start at 0 and count in 5s until 180 is reached
        # angle will be updated every loop so we will use it to set the servo angle

    for angle in range(180, 0, -5):  # 180 to 0 degrees, 5 degrees at a time
        # angle will be updated every loop so we will use it to set the servo angle

Another powerful tool that can be used with for loops is lists which is a sequence of variables put together. For example, here is a list of floats, integers and strings with a for loop looping through them.

test_list = [12, 447, 1344, 134, 234.23, "hello"]

for i in test_list:

This script will print out all the elements of integers, floats and strings from that list in order. Lists can be used with for loops like this to loop over strings or custom number sequences that would be impossible to make with the range function.

3.5 While Loops, Breaks, & Continues

While loops are the other type of loop we can use and they utilise the boolean logic statements we constructed before. Whilst for loops are designed to loop a set and a known amount of times, while loops will keep continuously looping a section of code until the statement we give it returns false. An example of this is that we might use it to keep checking the temperature until it equals 40 degrees.

We have already been using while loops with the main while true loop in our scripts. In that instance, we directly feed it the TRUE boolean logic which will keep it looping forever.

In this example, we create a reaction tester game and wait for a button press with while loops. We also use a handy function called tick_ms, which can be called to get the time in milliseconds since the board was powered on, and we use it to measure the passing of time.

from machine import Pin, Timer
import time
import random

button = Pin(15, Pin.IN, Pin.PULL_DOWN)  # Initialise button
led = Pin(16, Pin.OUT)                 # Initialise LED

while True:
    print("Press the button to start the game.")
    # reset button state
    button_state = 0
    # this while loop waits until the button is pressed
    while button_state == 0:
        button_state = button.value() 

    # Wait for a random time
    time.sleep(random.randint(2, 5))  # Wait for 2 to 5 seconds randomly

    # Turn on the LED

    # Start measuring reaction time from tick_ms
    start_time = time.ticks_ms()
    # reset button state
    button_state = 0
    # wait for the button to be pressed again
    while button_state == 0:
        button_state = button.value()

    # Calculate reaction time
    reaction_time = time.ticks_ms() - start_time

    # Turn off the LED

    print("Your reaction time is:",reaction_time, "milliseconds")

    # Add a small delay before the next round starts

2 other really handy functions to know are continue and break, which can be used with both while and for loops. They are a little bit situational, but having them in your maker toolbox is vitally important. 

  • The break function can be called inside of a loop and when it does, the while or for loop will immediately finish and move on to the code after the loop.
  • The continue function can also be called inside of the loop and when it does, the loop will end its current iteration and move onto the next cycle of the loop.

These can be used to effectively put a stop to loops or to skip certain cycles in loops.

3.6 Functions & Global Variables

While functions technically aren't a part of logic and decision-making, they complement this idea well as they can be used to easily control the flow of code.

Functions are a way to store a section of code in a nice and self-contained section of our script. We give that section of code a name, and then we can run that code by calling that name. This means that functions are great in managing code that needs to be repeated multiple times as we can just call it repeatedly.

Functions don't have to just be a list of instructions as we can also pass information into the function, and pull information out of it. This can be used for example to take in numbers, perform calculations on it, and return an answer.

When using functions, something to be careful of is global and local variables. When we create a variable in a function it is known as a local variable and will ONLY exist inside of that function. When the function ends... the variable ends. If we create a variable outside of a function, this is called a global variable and these are the variables we are used to dealing with. We can access global variables inside of a function as well.

Here is an example of functions in use, we use them to create a moving average filter to smooth out some sensor noise. Pay careful attention to the use of local and global variables, as well as how information is passed in and out of functions.

from machine import ADC, Pin
import time

# Initialize the ADC for the potentiometer
pot = ADC(Pin(26))

    # thie function reads potentiometer, assigns it to current reading, then shifts all the readings in the window by 1.
def update_window():
    global current_reading, last_reading1, last_reading2, last_reading3, last_reading4
    last_reading4 = last_reading3   
    last_reading3 = last_reading2
    last_reading2 = last_reading1
    last_reading1 = current_reading
    current_reading = pot.read_u16()

    # takes in 5 numbers and calculates the average of them
def calculate_average(val1, val2, val3, val4, val5):
    # calculate the average of the 4 numbers
    total = val1 + val2 + val3 + val4 + val5
    average = total / 5
    return average

# Initialize variables for current and the last 4 readings
current_reading = pot.read_u16()
last_reading1 = pot.read_u16()
last_reading2 = pot.read_u16()
last_reading3 = pot.read_u16()
last_reading4 = pot.read_u16()

while True:

    # Update readings

    # Calculate moving average
    moving_avg = calculate_average(current_reading, last_reading1, last_reading2, last_reading3, last_reading4)

    # Print the moving average
    print("Raw Data:", current_reading, "Moving average:", moving_avg)

    # Wait before the next reading
    time.sleep(0.1)  # Adjust the delay as needed

This is just one example of using functions and although this code could be easily written without the use of them, functions make it easier to read as our while true loop is less cluttered. The code doesn't technically need functions, it just makes life easier for us humans.

Chapter 4 coming soon...

Chapter 4: Advanced IO

4.1 Introduction to Advanced IO

Now we start to get into the cool stuff. With basic IO we were reading sensor data by interpreting voltage levels, but with advanced IO, our sensors now instead send that data by rapidly changing the voltages on their pins to strings of information.

This system of communication is called a com protocol, which is just a set of rules or a standard that devices can use to communicate data. The pico has native support for 3 protocols, UART, SPI and I2C, all of which will be explored in this chapter.

With com protocols, we start using a lot of things called modules. Modules are just self-contained sensors or devices that take in power, and when connected to the pico via a com protocol, provide information. When we use a module with a com protocol, it will send information to the pico in strings or numbers directly, instead of trying to interpret voltages (like with an analog temperature sensor).

These modules are often extremely library-dependent, meaning that every device will have a different library and chunk of code to use them. This means that this chapter becomes a little harder to follow along with without having the same hardware we use in the examples. If you wish to follow along you'll need another Pico for UART, a micro SD card reader for SPI, and an OLED and atmospheric sensor for the I2C video. If you have your own hardware using these com protocols it is still possible to follow along with the videos outside of the coding section. However, if there is one thing I could recommend following along with, it would be using the OLED screen in the I2C video as it is an incredibly powerful addition to any project.

4.2 UART

UART or Universal Asynchronous Receiver Transmitter, is one of the most commonly used communication protocols out there thanks to its simplicity and robustness. It allows 2 devices to talk to each other over 2 wires, 1 for sending data, and 1 for receiving it. This allows you to connect a Pico to other devices including UART modules like this GPS, computers like a Raspberry Pi 5, and other microcontrollers like an Arduino or another Pico, which we will be going through as an example in this video.

Using UART is incredibly easy in a MicroPython Environment as we don't need any external libraries and can send and receive data with simple commands. In the sample code below we send data from one Pico (Pico A), by first setting up an instance of UART, and then using the .write() function to send string data.

from machine import UART, Pin # we need to import UART to use it
import time

# Initialize UART 1 on Pico A, TX pin is GP4 and RX pin is GP5
test_uart = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5))

while True:
    test_uart.write('ON')  # Send 'ON' message to Pico B
    time.sleep(2)      # Wait for 2 seconds
    test_uart.write('OFF') # Send 'OFF' message to Pico B
    time.sleep(2)      # Wait for 2 seconds

Then on our second Pico (Pico B), we receive the messages with the .read() function, and then use if checks to compare the received string to control the onboard LED.

from machine import UART, Pin

# Initialize UART 1 on Pico B, TX pin is GP4 and RX pin is GP5
test_uart2 = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5))

# set up onboard LED
led = Pin("LED", Pin.OUT)

while True:
    # Check if anything is available in buffer
    if test_uart2.any():
        # recieve and store the message in a variable
        message = test_uart2.read().decode() # this .decode() removes the byte string format
        # control the led based on the string
        if message == "ON":
        elif message == "OFF":

Although UART is nice and simple, there are a few things to watch out for. When reading in data on the pico, it's often a good idea to check the buffer which holds incoming data while it's waiting to be received. This is done with the .any() function inside the if statement which returns true when something is in the buffer. Another function used is .decode() which removes the byte string format which is just a by-product of using UART, remove it and see this funky format if you want. Another thing to keep in mind is that you can only send strings in a MicroPython environment, so if you wish to send an integer, you must first convert it to a string with str().

And finally, the most important thing to remember about UART is that you MUST CONNECT RX TO TX AND TX TO RX. This is because if one pico transmits data, you want to receive it on the other. This is something that trips up even seasoned makers.

4.3 SPI

SPI or Serial Peripheral Interface, is a blazingly fast com protocol that allows us to connect multiple devices together and have them all communicating with each other at once. However, having multiple devices connected at once is a little complex and there is a management system called controller/target architecture. You may have heard the master/slave terminology used, but this is being phased out. However, a new standard is yet to be properly established so you may hear many other names used

Regardless, it all works the same with the controller (the Pico in our case) managing and conducting all the communications, and the target devices (sensors and modules) follow these directions. To do this, SPI uses 4 wires, a shared clock wire to keep all these interactions in sync, a wire for the controller to send data to the target devices, a wire for the target devices to send data to the controller, and a chip select wire (although you may need more than one) which is used by the controller to talk to the targets.

SPI is a very fast protocol, whilst UART typically runs at a baud rate in the 100's of thousands at the higher end, SPI can have a baud rate into the 10s of millions. This means that it is great in applications that require high data transfer rates. In this example, we utilise this ability by using an SD card adapter module to perform some basic data logging.

This was adapted from our Makerverse SD card guide if you want to take a look.

Here is a link to the library (you can right-click and hit save link as to download it).

from machine import SPI, Pin, ADC 
import sdcard, uos
import time
import random

# Here we intialise our SPI peripheral, and then send it off to the library to use
spi = SPI(1,sck=Pin(14), mosi=Pin(15), miso=Pin(12))
cs = Pin(13)
sd = sdcard.SDCard(spi, cs)

# Here we mount the SD card so that our Pico can see it
uos.mount(sd, '/sd')

print("Starting ADC samples")

# Open a text file on the sd card, then generate a number and write it to the file
with open('/sd/adcData.txt', "w") as f:
    while True:
        x = random.randint(0, 100)
        f.write(str(x)) # Write sample data
        f.write('\n') # A new line
        f.flush() # Force writing of buffered data to the SD card

As great as SPI is, it lies in a bit of a niche. If we were to connect a pico to another microcontroller or computer, it may be wise to opt for UART due to its simplicity and robustness. Although many SPI sensor modules exist, if we were to connect several of them together, it may be easier to use I2C. So you'll often find SPI playing to its strength of its high speeds in applications like this camera module, e-ink displays, and higher resolution OLED displays.

4.4 I2C

I2C or Inter-Integrated Circuit, is an incredible protocol that has the ability to connect over 100 devices with only 2 wires! 1 wire is dedicated to a clock to keep everything in sync, and another is for data to be sent along. Like SPI, it achieves this with the controller/target terminology to carefully manage the flow of information along this single data wire.

I2C devices are also incredibly easy to use. You connect the SCL (clock) pin of the device to the SCL pin of the Pico, and the SDA (data) pin to the Pico's SDA pin. Any additional devices you wish to connect will follow the same rules. In this video, we go through an example of this by connecting up an I2C OLED display and an atmospheric sensor to the same pins of a Pico. 

If you are following along with the same hardware, you will need this OLED library, the atmospheric sensor library, as well as the additional unified library for it (and you can also visit the documentation page for more sample code).

import ssd1306
import time
from machine import Pin, I2C
from PiicoDev_BME280 import PiicoDev_BME280

# Initialize I2C perhipheral
i2c = I2C(0, scl=Pin(9), sda=Pin(8), freq=400000)

# Initialise OLED display
oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3c)

# Initialise Atmospheric Sensor
sensor = PiicoDev_BME280() # initialise the sensor

while True:
    tempC, presPa, humRH = sensor.values() # read all data from the sensor
    # Clear OLED display (this wont take effect till we .show())

    # Display our text and variables, the right 2 numbers are the x and y coordinates of the text
    oled.text("Temp:", 5, 10)
    oled.text(str(tempC), 60, 10)
    oled.text("Press:", 5, 30)
    oled.text(str(presPa), 60, 30)
    oled.text("Humid:", 5, 50)
    oled.text(str(humRH), 60, 50)
    # Push changes to the display

One of the issues with connecting many I2C devices is dealing with address conflicts. Addresses are used in I2C to keep track of who is currently sending data, so every address on an I2C bus must be unique. The problem lies in that addresses are hardware-based and 2 of the same devices will share the same address from the factory. You may be lucky and find switches on your device that can be used to change its address, or you may need to resolder a resistor. What ever is possible, its always a good idea to plan out address conflicts in the planning stage of your project.

Have a question? Ask the Author of this guide today!

Please enter minimum 20 characters

Your comment will be posted (automatically) on our Support Forum which is publicly accessible. Don't enter private information, such as your phone number.

Expect a quick reply during business hours, many of us check-in over the weekend as well.



Please continue if you would like to leave feedback for any of these topics:

  • Website features/issues
  • Content errors/improvements
  • Missing products/categories
  • Product assignments to categories
  • Search results relevance

For all other inquiries (orders status, stock levels, etc), please contact our support team for quick assistance.

Note: click continue and a draft email will be opened to edit. If you don't have an email client on your device, then send a message via the chat icon on the bottom left of our website.

Makers love reviews as much as you do, please follow this link to review the products you have purchased.