for educators

this is prep work, not the workshop

this page is for whoever is setting up kits ahead of time, installing CircuitPython, flashing code, and testing hardware. attendees never see any of this. by the time someone sits down at the workshop, their kit is already fully preloaded and working. all they do is tweak the code, wire the button, and build their enclosure, see workshop day for that shorter version.

what you'll need: an Adafruit RP2040 Feather ThinkInk, a 2.13" 212×104 flexible monochrome eInk display (ILI0373 chipset), a USB-C cable, and a computer to program from. for a full parts list and alternatives, see resources.

quick reference: flashing a batch

if you've already been through the full setup once and just need a fast checklist for preparing multiple units, here it is. full detail on each step is below.

  1. disconnect the eInk display before entering bootloader mode, for every board.
  2. flash CircuitPython 10.x, if it isn't already installed.
  3. copy lib/adafruit_il0373.mpy and lib/adafruit_display_text/ onto the board.
  4. copy the final code.py onto the board.
  5. reconnect the display and test: the intro screen should appear, press the button once, and confirm it starts cycling through messages with the countdown printing on the serial console.

keep a simple tally as you go, which units pass cleanly versus need troubleshooting. that tells you a lot about consistency across the batch before the workshop itself.

0. seat the display

before anything else, the eInk display's ribbon cable needs to be seated into the Feather's display connector. this only needs to happen once per kit.

Step 0: using the latches on the microcontroller's docking port, insert the display's ribbon cable and close the latches to secure it. A closer look at the display connector on the Feather board, showing where the ribbon cable seats into the docking port.

the latches need to be open before you insert the cable, and closed firmly afterward to hold it in place. the cable only fits one way, don't force it if it's not sliding in easily.

1. install CircuitPython

before you start: disconnect the eInk display from the Feather. having it connected can interfere with the board entering bootloader mode. reconnect it once CircuitPython is installed.

  1. unplug the board completely.
  2. press and hold the BOOTSEL button (this is the button on the board itself, labeled BOOT or BOOTSEL, not the reset button).
  3. while still holding it, plug in the USB-C cable.
  4. wait a second or two, then release the button. this "boot on start" method is more reliable than double-pressing reset while already powered on.
  5. a drive named RPI-RP2 should appear. check your file browser (Finder on Mac, File Explorer on Windows) under "Locations" or "This PC", it shows up like a USB flash drive.
  6. go to circuitpython.org, click the Downloads tab in the nav, and search for your board: "Adafruit Feather RP2040 ThinkInk". don't pick a generic RP2040 file, other variants have different pin mappings.
  7. choose the stable release (10.x), and download the single .uf2 file. it will land in your computer's Downloads folder unless you chose a different save location.
  8. open your Downloads folder, find that .uf2 file, and drag it onto the RPI-RP2 drive.
  9. the board restarts automatically. the drive is now named CIRCUITPY, that means CircuitPython is installed.
  10. now reconnect the eInk display.

if the RPI-RP2 drive never appears: confirm the display is disconnected, try a different USB-C cable (some are charge-only), try a different port, or unplug fully and retry the BOOTSEL sequence.

2. add the required libraries

go to the CircuitPython Library Bundle page, and download the latest zip matching your exact CircuitPython major version (check boot_out.txt on your CIRCUITPY drive if you're not sure which version you're running, e.g. "10.x"). bundle releases get periodic bug fixes, so grabbing the newest one for your version line, rather than an old cached download, helps keep all your kits consistent. it will land in your Downloads folder. unzip it, then from inside the unzipped folder's lib subfolder, drag these two items onto your CIRCUITPY drive, into a folder there also named lib (create that folder if it doesn't exist yet):

names must be exact, underscores not hyphens. a mismatched name is the most common reason a library fails to import.

3. add the code

when you first open code.py on a fresh CIRCUITPY drive, you'll likely see a single line already there: print("Hello World!"). that's just a default test file, not part of this project. delete it, then paste in the real code below.

  1. if you don't already have a code editor for CircuitPython, install Mu Editor, a free, beginner-friendly editor built specifically for it. you'll use it to edit and paste in code, and to view the serial console (see using the serial monitor below).
  2. copy the code onto your CIRCUITPY drive as code.py, replacing whatever was there. (use the copy button on the code page to grab the whole file at once.)
  3. the board should auto-run it. if not, press reset once.
  4. watch the serial console as it runs, it prints its progress at each step.
  5. wait for "refresh complete" before judging what's on screen. e-ink flickers through intermediate frames mid-refresh, this can look like smudging and is completely normal.

hardware: where things connect

Step 1: wrap the wire at the end of the alligator clips around microcontroller pins labeled GND and A2. Make sure they're secure and not touching other pads. A labeled close-up of the A2 and GND pins on the Feather board, with wires already connected.

the button needs two wires: one leg to pin A2, the other leg to any GND pin. no resistor is needed, the code already uses the board's internal pull-up resistor.

most small tactile buttons have 4 legs in 2 internally-connected pairs. your two wires need to land on opposite pairs, not two legs from the same side, otherwise the button reads as permanently pressed (or never pressed) no matter what. if this happens, try moving one wire to a different leg and test again.

the eInk display connects via its 24-pin ribbon cable into the Feather's display connector, this only fits one way, don't force it.

using the serial monitor

the serial console (accessed through the REPL in Mu, or any serial terminal) shows live text output from the board while your code runs. this project's code prints a line at almost every step ("starting up", "display ready", "showing: ...", etc.), specifically so you can see exactly where something goes wrong instead of guessing.

if a kit isn't behaving, check the serial console first, before touching any wiring. an error message here tells you far more than staring at the display ever will.

troubleshooting notes

real issues we hit while testing, and what fixed them.

why the display can't refresh quickly

this isn't a bug, it's physics. e-ink works by physically moving charged ink particles inside each pixel, not by lighting up transistors. that movement takes real time, and it can't be rushed by better code.

the manufacturer's own datasheet for this exact panel states it directly: "each update interval time should be minimum at 180 seconds" for the panel's rated 5-year, 1,000,000-cycle lifespan to hold. the CircuitPython driver enforces this, attempting a refresh before that window passes raises a RuntimeError: Refresh too soon, on purpose.

it's possible to override this in code (a refresh_time parameter exists), but doing so doesn't make the physical ink move any faster, it just lets the code ask again sooner than the panel can actually deliver. the likely result is a ghosted or incomplete image, and real wear on the panel over repeated cycles. we chose not to override it.

this is exactly the kind of limitation this workshop asks you to design with, not around. rather than fighting the display's pace, the wait itself becomes part of what pressing the button means, not a flaw to hide.

code runs with no error, but nothing shows

bitmaps must be created with value_count=2, not 1. using 1 technically creates a zero-bit bitmap that can silently fail to render, even though the code runs without error. this was the root cause of a lot of confusing "it's not working" testing, not faulty hardware.

screen looks blurry or smudged

e-ink flickers through several frames during a refresh. don't judge the screen until the code confirms the refresh is complete. checking mid-refresh looks exactly like a real problem, and usually isn't one.

the RPI-RP2 drive won't appear

disconnect the eInk display before entering bootloader mode. the display connected during this step can prevent the drive from showing up at all.

SyntaxError referencing <stdin>

not a code problem. usually a stray keypress sent into the REPL prompt directly. press Ctrl+D to reload, or press the physical reset button, and try again.

a display seems consistently faulty

before assuming hardware failure: re-test with the latest known-good code first. an earlier bug in our own script caused some units to look broken when they weren't. if it's still inconsistent after that, isolate by swapping the display to a different board, and the board to a different display, whichever component the problem follows is the actual fault.

OLED version: ValueError: Unable to find I2C Display at 3c

this is an OLED-alternative issue, not an eInk one. most SSD1306 OLEDs answer at I2C address 0x3c, but the exact address is set by a jumper on the OLED board itself, so some units answer at 0x3d instead. across a batch of kits, expect a mix.

the current OLED code handles this automatically: it scans the I2C bus at startup and uses whichever address actually responds, so this shouldn't come up if attendees are running the latest version from the code page.

if it does come up anyway, it almost always means the STEMMA QT cable isn't fully seated on one end, not a wrong address. reseat both ends first. to check what's actually on the bus, run this in the REPL:

import board
i2c = board.STEMMA_I2C()
while not i2c.try_lock():
    pass
print([hex(x) for x in i2c.scan()])
i2c.unlock()

an empty list means nothing is responding, check the cable. a different address showing up means that's the OLED, and the auto-detect logic will pick it up on its own.

this display holds its image with no power needed. once it shows text, you can even unplug it and the image will remain.

this is a living document. it gets updated as we test more units and find new issues, so it stays accurate for whoever builds one after us.

for a full parts list and classroom-friendly alternatives, see resources.