Code the MEMENTO
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries, and the code.py file. To get everything you need, click on the Download Project Bundle button below, and uncompress the .zip file.
Connect your computer to the MEMENTO via a known good USB power+data cable. A new flash drive should show up on your computer named CIRCUITPY in the File Explorer or Finder (depending on your operating system/computer type).
Drag the lib directory and the memento_code.py file onto your MEMENTO board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
Once you've copied the files to the MEMENTO's CIRCUITPY drive, rename the memento_code.py file to code.py otherwise the code won't run on startup.
# SPDX-FileCopyrightText: 2023 Brent Rubell & 2026 John Park for Adafruit Industries
#
# An open-source IoT ePaper camera with the Adafruit MEMENTO and MagTag
#
# SPDX-License-Identifier: Unlicense
import binascii
import gc
import os
import ssl
import time
import traceback
import adafruit_imageload
import adafruit_pycamera
import adafruit_requests
import bitmaptools
import displayio
import gifio
import socketpool
import wifi
from adafruit_io.adafruit_io import IO_HTTP, AdafruitIO_RequestError
print("CircuitPython ePaper Camera")
### WiFi ###
aio_username = os.getenv("ADAFRUIT_AIO_USERNAME")
aio_key = os.getenv("ADAFRUIT_AIO_KEY")
print(f"Connecting to {os.getenv('CIRCUITPY_WIFI_SSID')}")
wifi.radio.connect(
os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")
)
print(f"Connected to {os.getenv('CIRCUITPY_WIFI_SSID')}!")
# Keep pool global but recreate requests/io each time
pool = socketpool.SocketPool(wifi.radio)
# Adafruit IO feed name
feed_name = "epapercam"
# Initialize memento camera
pycam = adafruit_pycamera.PyCamera()
pycam.effect = 2 # B&W
pycam.resolution = 0 # 240x240
pycam.led_level = 1
pycam.led_color = 4 # white
pycam.led_color = 0
time.sleep(0.1)
print(f"Camera now at: {pycam.camera.width}x{pycam.camera.height}")
pycam.display.brightness = 0.7
capture_count = 0
def load_and_dither_image(temp_jpeg):
"""Load JPEG and create dithered bitmap."""
print("[] Loading bitmap...")
captured_bitmap, _ = adafruit_imageload.load(temp_jpeg)
print(f"[] Loaded bitmap: {captured_bitmap.width}x{captured_bitmap.height}")
print("[] Creating dither bitmap...")
dithered_frame = displayio.Bitmap(
captured_bitmap.width, captured_bitmap.height, 65535
)
print("[] Dithering...")
bitmaptools.dither(
dithered_frame, captured_bitmap, displayio.Colorspace.RGB565_SWAPPED
)
print("[] Dithered!")
# Free the captured bitmap
captured_bitmap = None
gc.collect()
print(f"[] Cleared bitmap, free memory: {gc.mem_free()} bytes")
return dithered_frame
def create_gif_from_bitmap(dithered_frame, temp_filename):
"""Create GIF file from dithered bitmap."""
print("[] Creating GIF file...")
with open(temp_filename, "wb") as f:
with gifio.GifWriter(
f,
dithered_frame.width,
dithered_frame.height,
displayio.Colorspace.RGB565_SWAPPED,
dither=True,
) as g:
g.add_frame(dithered_frame, 1)
print("[] GIF file created")
def send_to_adafruit_io(encoded_data):
"""Send encoded image data to Adafruit IO."""
# Create FRESH requests and IO objects for this send
print("[] Creating fresh network session...")
requests = adafruit_requests.Session(pool, ssl.create_default_context())
io = IO_HTTP(aio_username, aio_key, requests)
# Get feed
try:
feed_camera = io.get_feed(feed_name)
except AdafruitIO_RequestError:
feed_camera = io.create_new_feed(feed_name)
print("[] Fresh session created")
# Send to IO
print("[] Sending to Adafruit IO...")
io.send_data(feed_camera["key"], encoded_data)
print("[] Sent to IO successfully!")
# Explicitly delete requests and io to free sockets
print("[] Cleaning up network objects...")
del io
del requests
gc.collect()
print("[] Network objects cleaned up")
def capture_send_image():
"""Captures an image, dithers it to black and white, saves as GIF, and sends to Adafruit IO."""
# pylint: disable=too-many-statements
global capture_count # pylint: disable=global-statement
capture_count += 1
print(f"\n=== CAPTURE #{capture_count} START ===")
# Force garbage collection before capture
gc.collect()
print(f"[] Free memory: {gc.mem_free()} bytes")
try:
print("[] Starting autofocus...")
pycam.autofocus()
print("[] Autofocus complete")
print("[] Capturing JPEG...")
jpeg_data = pycam.capture_into_jpeg()
print(f"[] Captured JPEG: {len(jpeg_data)} bytes")
print("[] Writing temp file...")
temp_jpeg = "/sd/temp_capture.jpg"
with open(temp_jpeg, "wb") as f:
f.write(jpeg_data)
print("[] Temp file written")
# Clear jpeg_data from memory
jpeg_data = None
gc.collect()
print(f"[] Cleared JPEG data, free memory: {gc.mem_free()} bytes")
# Load and dither image
dithered_frame = load_and_dither_image(temp_jpeg)
print("[] Displaying preview...")
pycam.blit(dithered_frame)
pycam.display.refresh()
print("[] Preview displayed")
# Create GIF
temp_filename = "/sd/temp_doorbell.gif"
create_gif_from_bitmap(dithered_frame, temp_filename)
# Free dithered_frame
dithered_frame = None
gc.collect()
print(f"[] Cleared dither frame, free memory: {gc.mem_free()} bytes")
print("[] Reading GIF...")
with open(temp_filename, "rb") as f:
gif_data = f.read()
print(f"[] Read GIF: {len(gif_data)} bytes")
print("[] Encoding to base64...")
encoded_data = binascii.b2a_base64(gif_data).strip().decode("ascii")
print(f"[] Encoded: {len(encoded_data)} chars")
# Free gif_data
gif_data = None
gc.collect()
print(f"[] Cleared GIF data, free memory: {gc.mem_free()} bytes")
# Send to Adafruit IO
send_to_adafruit_io(encoded_data)
# Free encoded data
encoded_data = None
gc.collect()
print(f"[] Cleared encoded data, free memory: {gc.mem_free()} bytes")
pycam.tone(3200, 0.1)
except Exception as err: # pylint: disable=broad-except
print(f"[ERROR] Exception during capture: {err}")
traceback.print_exception(type(err), err, err.__traceback__)
pycam.tone(400, 0.3) # Low error tone
finally:
print("[] Resuming live preview...")
try:
pycam.live_preview_mode()
print("[] Live preview mode set")
time.sleep(0.3)
print("[] Waited for stabilization")
pycam.display.refresh()
print("[] Display refreshed")
except Exception as resume_err: # pylint: disable=broad-except
print(f"[ERROR] Exception resuming preview: {resume_err}")
gc.collect()
print(f"[] Final free memory: {gc.mem_free()} bytes")
print("=== CAPTURE COMPLETE ===\n")
print("ePaper camera ready.")
pycam.tone(800, 0.1)
pycam.tone(1200, 0.05)
while True:
try:
frame = pycam.continuous_capture()
if frame and hasattr(frame, "width"):
pycam.blit(frame)
else:
print("[WARNING] Invalid frame")
pycam.keys_debounce()
if pycam.shutter.short_count:
print("\n>>> SHUTTER PRESSED <<<")
pycam.tone(1200, 0.05)
pycam.tone(1600, 0.05)
capture_send_image()
print(">>> Ready for next capture <<<\n")
if pycam.up.rose:
pycam.led_level = (pycam.led_level + 1) % 5
print(f"LED brightness: {pycam.led_level}")
pycam.led_color = 4
time.sleep(0.2)
pycam.led_color = 0
except Exception as main_err: # pylint: disable=broad-except
print(f"[ERROR] Main loop exception: {main_err}")
traceback.print_exception(type(main_err), main_err, main_err.__traceback__)
time.sleep(0.5)
time.sleep(0.01)
How It Works
The ePaper Camera system uses two Adafruit boards working together: the MEMENTO camera captures black and white photos, and the MagTag e-ink displays them. Let's walk through how the code makes this happen.
Setup and Initialization
When the MEMENTO powers on, it first connects to your WiFi network using credentials stored in settings.toml. It then creates a socket pool for network communications and gets ready to talk to Adafruit IO, where images will be uploaded.
wifi.radio.connect(
os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")
)
pool = socketpool.SocketPool(wifi.radio)
Next, the PyCamera object initializes the camera hardware. It is configured for black and white photography (effect = 2) at 240x240 resolution, set the LED ring to a medium brightness, and turn on the display so you can see what you're framing:
pycam = adafruit_pycamera.PyCamera() pycam.effect = 2 # B&W pycam.resolution = 0 # 240x240 pycam.display.brightness = 0.7
The Main Loop
The camera runs in a continuous loop, always showing a live preview. This happens in the main while True: loop at the bottom of the code. Every 10 milliseconds, it grabs a frame from the camera and displays it:
frame = pycam.continuous_capture()
if frame and hasattr(frame, "width"):
pycam.blit(frame)
The code also checks for button presses. If you press the shutter button, it calls capture_send_image() to take a photo and upload it. If you press the up button, it cycles through LED brightness levels (0-4) so you can adjust the light for different shooting conditions.
Capturing and Processing Images
When you press the shutter button, the capture_send_image() function runs. Here's the journey your photo takes:
Step 1: Capture
First, the camera autofocuses and captures a JPEG image:
pycam.autofocus() jpeg_data = pycam.capture_into_jpeg()
Step 2: Dithering
The JPEG is saved temporarily to the SD card, then loaded back as a bitmap. We create a second bitmap and apply Floyd-Steinberg dithering, which converts the grayscale image into a beautiful black and white pattern that looks great on e-ink displays:
bitmaptools.dither(
dithered_frame, captured_bitmap, displayio.Colorspace.RGB565_SWAPPED
)
Step 3: GIF Creation
We save the dithered bitmap as a single-frame GIF file:
with gifio.GifWriter(f, width, height, colorspace, dither=True) as g:
g.add_frame(dithered_frame, 1)
Step 4: Encoding
The GIF is read back from the SD card and encoded as base64 text to send over the internet:
encoded_data = binascii.b2a_base64(gif_data).strip().decode("ascii")
Step 5: Uploading
Finally, we create a fresh network session and send the encoded image to your Adafruit IO feed named epapercam:
io.send_data(feed_camera["key"], encoded_data)
Memory Management
After each major step, the code explicitly frees memory and runs garbage collection to ensure we don't run out of space:
jpeg_data = None gc.collect()
We also create a fresh requests session for each upload and delete it afterward. This prevents socket exhaustion - the ESP32 only has about 10-12 network sockets available, and if we don't clean them up properly, the camera would freeze after a few photos.
Error Handling and Recovery
The code includes extensive error handling. If something goes wrong during capture, it plays a low error tone and always returns the camera to live preview mode in the finally: block:
finally:
pycam.live_preview_mode()
time.sleep(0.3)
pycam.display.refresh()
LED Ring Brightness Control
The up button on the MEMENTO cycles through five LED brightness levels. Level 0 turns the LEDs off completely, while level 4 is maximum brightness:
if pycam.up.rose: pycam.led_level = (pycam.led_level + 1) % 5
Page last edited January 28, 2026
Text editor powered by tinymce.