If you’d be satisfied with just the blinkenlights part of the project (this is what most people are after!), we can do this with a Pico RP2040 microcontroller board replacing the Raspberry Pi 4. No complex operating system to boot up, it’s even easier to set up and use.

If you know your way around code and hardware a bit, actually any CircuitPython-compatible board with two I2C buses can work. The Pico is super inexpensive and readily available though, so it is used as the reference design.

If this is your first time using CircuitPython on the Pico RP2040 board, please begin with this guide which provides an introduction and download & installation instructions.

So at this point, you should have CircuitPython installed on the Pico board.

To download the code for this project, click the “Download Project Bundle” button below. This brings together some required CircuitPython libraries, so everything is quickly and easily copied to the CIRCUITPY drive.

If you currently have a CircuitPython project on the Pico board, copy the files off to somewhere for safe keeping.

Unzip the project bundle archive, look inside the resulting folder (a couple layers down if needed), and copy the code.py file and lib folder to the CIRCUITPY drive. The drive contents should then appear like so (you can ignore the boot_out.txt file, it’s auto-generated on startup):

# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
CircuitPython random blinkenlights for Little Connection Machine. For
Raspberry Pi Pico RP2040, but could be adapted to other CircuitPython-
capable boards with two or more I2C buses. Requires adafruit_bus_device
and adafruit_is31fl3731 libraries.

This code plays dirty pool to get fast matrix updates and is NOT good code
to learn from, and might fail to work with future versions of the IS31FL3731
library. But doing things The Polite Way wasn't fast enough. Explained as
we go...
"""

# pylint: disable=import-error
import random
import board
import busio
from adafruit_is31fl3731.matrix import Matrix as Display

BRIGHTNESS = 40  # CONFIGURABLE: LED brightness, 0 (off) to 255 (max)
PERCENT = 33  # CONFIGURABLE: amount of 'on' LEDs, 0 (none) to 100 (all)

# This code was originally written for the Raspberry Pi Pico, but should be
# portable to any CircuitPython-capable board WITH TWO OR MORE I2C BUSES.
# IS31FL3731 can have one of four addresses, so to run eight of them we
# need *two* I2C buses, and not all boards can provide that. Here's where
# you'd define the pin numbers for a board...
I2C1_SDA = board.GP18  # First I2C bus
I2C1_SCL = board.GP19
I2C2_SDA = board.GP16  # Second I2C bus
I2C2_SCL = board.GP17

# pylint: disable=too-few-public-methods
class FakePILImage:
    """Minimal class meant to simulate a small subset of a Python PIL image,
    so we can pass it to the IS31FL3731 image() function later. THIS IS THE
    DIRTY POOL PART OF THE CODE, because CircuitPython doesn't have PIL,
    it's too much to handle. That image() function is normally meant for
    robust "desktop" Python, using the Blinka package...but it's still
    present (but normally goes unused) in CircuitPython. Having worked with
    that library source, I know exactly what object members its looking for,
    and can fake a minimal set here...BUT THIS MAY BREAK IF THE LIBRARY OR
    PIL CHANGES!"""

    def __init__(self):
        self.mode = "L"  # Grayscale mode in PIL
        self.size = (16, 9)  # 16x9 pixels
        self.pixels = bytearray(16 * 9)  # Pixel buffer

    def tobytes(self):
        """IS31 lib requests image pixels this way, more dirty pool."""
        return self.pixels


# Okay, back to business...
# Instantiate the two I2C buses. 400 KHz bus speed is recommended.
# Default 100 KHz is a bit slow, and 1 MHz has occasional glitches.
I2C = [
    busio.I2C(I2C1_SCL, I2C1_SDA, frequency=400000),
    busio.I2C(I2C2_SCL, I2C2_SDA, frequency=400000),
]
# Four matrices on each bus, for a total of eight...
DISPLAY = [
    Display(I2C[0], address=0x74, frames=(0, 1)),  # Upper row
    Display(I2C[0], address=0x75, frames=(0, 1)),
    Display(I2C[0], address=0x76, frames=(0, 1)),
    Display(I2C[0], address=0x77, frames=(0, 1)),
    Display(I2C[1], address=0x74, frames=(0, 1)),  # Lower row
    Display(I2C[1], address=0x75, frames=(0, 1)),
    Display(I2C[1], address=0x76, frames=(0, 1)),
    Display(I2C[1], address=0x77, frames=(0, 1)),
]

IMAGE = FakePILImage()  # Instantiate fake PIL image object
FRAME_INDEX = 0  # Double-buffering frame index

while True:
    # Draw to each display's "back" frame buffer
    for disp in DISPLAY:
        for pixel in range(0, 16 * 9):  # Randomize each pixel
            IMAGE.pixels[pixel] = BRIGHTNESS if random.randint(1, 100) <= PERCENT else 0
        # Here's the function that we're NOT supposed to call in
        # CircuitPython, but is still present. This writes the pixel
        # data to the display's back buffer. Pass along our "fake" PIL
        # image and it accepts it.
        disp.image(IMAGE, frame=FRAME_INDEX)

    # Then quickly flip all matrix display buffers to FRAME_INDEX
    for disp in DISPLAY:
        disp.frame(FRAME_INDEX, show=True)
    FRAME_INDEX ^= 1  # Swap buffers


# This is actually the LESS annoying way to get fast updates. Other involved
# writing IS31 registers directly and accessing intended-as-private methods
# in the IS31 lib. That's a really bad look. It's pretty simple here because
# this code is just drawing random dots. Producing a spatially-coherent
# image would take a lot more work, because matrices are rotated, etc.
# The PIL+Blinka code for Raspberry Pi easily handles such things, so
# consider working with that if you need anything more sophisticated.

If everything’s connected correctly and the code and libraries installed in the right place, you should get the blinkies. And that’s it! Just plug it into USB any time you need it.

If things do not work, you’ll want to open a serial connection to the CircuitPython console (such as with the Mu editor) and see what error messages it’s displaying. This is explained in the introductory CircuitPython guide previously mentioned.

For posterity, here’s that wiring diagram, showing the connections for this board:

If you see a RuntimeError or ValueError message in the serial console, it’s usually one of these things:

  • An SDA/SCL wire pair is swapped, either on the Pico board or on one or more matrices.
  • SDA and/or SCL are connected to the wrong pin(s).
  • Address select pads on one or more matrices are not correctly set; multiple matrices on the same I2C bus are set to the same address.
  • A poorly-soldered connection in one of the 4-way splitters.

Remember there are two I2C buses at play here, each with four matrices, each of which should have a unique address on that bus.

“Do as I say, not as I do”

This code does things that would get you an “F” in any programming class, so please don’t look to it as a shining example of anything.

The adafruit_is31fl3731 library normally provides easy X/Y pixel-setting functions and robust exception handling. Any well-behaved code really should be using the functions provided there, as documented in this guide. If you want to display other things on your Little Connection Machine and don’t need super-frequent updates, please program it that way…or build the Raspberry Pi version.

In order to get fast updates on the LED matrices, this project’s code plays games with the adafruit_is31fl3731 library to dump data directly to the matrices, bypassing the aforementioned functions. It relies on the fact that it’s just a random bit pattern, that there’s no coherent order that must be followed to produce an image. Handling that would add inordinate complexity to this code.

Such complexity is something the Raspberry Pi is perfectly equipped to handle… and if you go looking into the Pi code, you’ll see it treats all the matrices as if a single contiguous image, with numerous drawing and image-loading functions available through PIL, the Python Imaging Library. The CircuitPython blinky code is just parlor tricks…and that’s why it’s the only example we provide there.

This guide was first published on Apr 19, 2022. It was last updated on Apr 15, 2024.

This page (Code (CircuitPython)) was last updated on Apr 15, 2024.

Text editor powered by tinymce.