Overview
Build an ePaper camera display using the Adafruit MEMENTO camera and MagTag eink display so you can snap lofi pictures and transmit them over WiFi to your eInk photo frame!
A free Adafruit IO account acts as the intermediary between camera and display, through your own custom camera feed.
Page last edited January 28, 2026
Text editor powered by tinymce.
Get Started with Adafruit IO
Adafruit IO is integrated with your adafruit.com account so you don't need to create yet another online account! You need an Adafruit account to use Adafruit IO because we want to make sure the data you upload is available to only you (unless you decide to publish your data).
I have an Adafruit.com Account already
If you already have an Adafruit account, then you already have access to Adafruit IO. It doesn't matter how you signed up, your account will make all three available.
To access Adafruit IO, simply visit https://io.adafruit.com to start streaming, logging, and interacting with your data.
Create an Adafruit Account (for Adafruit IO)
An Adafruit account makes Adafruit content and services available to you in one place. Your account provides access to the Adafruit shop, the Adafruit Learning System, and Adafruit IO. This means only one account, one username, and one password are necessary to engage with the content and services that Adafruit offers.
If you do not have an Adafruit account, signing up for a new Adafruit account only takes a couple of steps.
Begin by visiting https://accounts.adafruit.com.
Click the Sign Up button under the "Need An Adafruit Account?" title, below the Sign In section.
This will take you to the Sign Up page.
Fill in the requested information, and click the Create Account button.
This takes you to your Adafruit Account home page. From here, you can access all the features of your account.
You can also access the Adafruit content and services right from this page. Along the top of the page, you'll see a series of links beginning with "Shop". To access any of these, simply click the link.
For example, to begin working with Adafruit IO, click the IO link to the right of the rest of the links. This is the same for the other links as well.
That's all there is to creating a new Adafruit account, and navigating to Adafruit IO.
Page last edited January 28, 2026
Text editor powered by tinymce.
Create Adafruit IO Feed
Feeds are the core of the Adafruit IO system. A feed holds data and meta-data that you have sent to Adafruit IO.
- Data is the information you want Adafruit IO to store. For example, image data from the MEMENTO's camera.
- Meta-data includes settings for the data such as privacy settings and data retention options.
Before using the ePaper camera, a new Adafruit IO Feed must be created to store the camera's image. Then, the feed's history must be disabled to allow larger data points to be sent to Adafruit IO, such as a large amount of data to represent an image.
To create a feed, navigate to your Adafruit IO Feeds page. Then, click New Feed.
Name the feed epapercamera and click Create.
The feeds list now lists an epapercamera feed. Click this feed.
Set History to OFF and click Save. This will change the feed's data storage from 1KB to 100KB, large enough to store image data.
Page last edited January 28, 2026
Text editor powered by tinymce.
MEMENTO Camera Setup
Here you'll assemble your MEMENTO camera and set it up with the CircuitPython installation and ePaper Camera code.
Page last edited January 28, 2026
Text editor powered by tinymce.
MEMENTO Assembly
Film Prep
OK, not that kind of film. But still, the first step in assembling your MEMENTO camera is to remove the protective film from the four threaded standoffs.
Battery Adhesive
Peel off one side of backing from the double-sided adhesive foam, then press it onto the main board where the battery will go, as shown.
Battery Mount
Insert the battery JST cable into the connector, then remove the other protective backing from the adhesive foam.
Press the battery into place as shown, being careful to dress the wires neatly.
LED Faceplate
You'll mount the LED faceplate using four of the provided M3 screws.
Place the faceplate over the MEMENTO board with the white triangle pointing at the shutter button. This assures the JST connectors of both boards are on the same edge.
Screw in the four screws.
Prep for Back Plate
Flip the MEMENTO over, then press gently on the TFT to make sure it is square and the adhesive strips are snug against the PCB.
Remove the protective film.
Fastener Prep
Place the four remaining screws through the back panel holes from the side of the board that will face you when assembled as shown.
Thread the four nuts onto the screws -- these will act as spacers.
Place the back panel onto the back of the MEMENTO so the buttons and screen line up.
Screw the four screws into the threaded standoffs.
JST Cable
We had a special short JST 3-pin cable made for the MEMENTO so the LED board can get power and data for the NeoPixels from the A1 port on the MEMENTO.
Plug the cable into the LED plate and MEMENTO A1 JST ports. The connectors can only go in one way.
Insert SD Card
Orient the micro SD card as shown and press it into the SD card reader slot -- you'll press it all the way in until it clicks and stops and then as you let off the pressure it'll click a second time. *snik snik!*
NOTE: You can use cards with up to 32GB capacity.
That's it, you've assembled your MEMENTO! Now, you're ready to get started taking pictures and hacking your MEMENTO camera! Flip the ON switch and the camera will boot up with the factory demo.
Page last edited January 28, 2026
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
import storage storage.erase_filesystem()
Your board will reboot after running this.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.
For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.
If you do not see the LED turning purple, you will need to reinstall the UF2 bootloader. See the Factory Reset page in this guide for details.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called CAMERABOOT. Drag the adafruit-circuitpython-adafruit_esp32s3_camera-etc.uf2 file to CAMERABOOT.
Page last edited January 28, 2026
Text editor powered by tinymce.
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.
MagTag Frame Setup
Here you'll assemble your MagTag, set up CircuitPython on it, and code it to act as the ePaper photo display.
Page last edited January 28, 2026
Text editor powered by tinymce.
Assembly
Install Buttons
Hold the top half of the enclosure face side down and place the four buttons into the holes. Then set it aside.
Install Battery (Optional)
Connect the optional 500mAh lipo battery to the MagTag. Use a piece of double-sided tap or mounting tack to keep the battery adhered to the MagTag's PCB.
Install MagTag
Get the bottom half of the enclosure and four M3 x 6mm long nylon screws.
Orient the MagTag with the bottom half of the enclosure. Place the MagTag into the bottom with the mounting holes lined up.
Secure MagTag
Flip the MagTag and bottom part and fasten the M3 screws into the four mounting holes.
Install Switch
Place the switch into the slot near the MagTag's on/off switch. The actuator of the on/off switch should fit in between the two nubs.
Join Top and Bottom
Orient the top and bottom half of the enclosure parts so they orient. While holding the switch in place, begin joining the two halves together making sure the four buttons stay in place.
Firmly join the two halves together until they snap fit closed.
Kickstand
To use the kickstand, locate the notch on the back side. To pop out the stand, fit your fingernail under the ridge of the stand and pull it out.
The stand can be stowed away by snaping it into the back side of the enclosure.
Assembled Case
The assembled case is ready to prop on your desk, or stick to your refrigerator or other metal surface using the magnetic feet.
Page last edited January 28, 2026
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
Click the link above and download the latest .BIN and .UF2 file
You can use a 9.x.x release for a pre-2025 MagTag. You must use a 10.x.x release for the updated MagTag 2025 Edition.
(depending on how you program the ESP32S2 board you may need one or the other, might as well get both)
Download and save it to your desktop (or wherever is handy).
Plug your MagTag into your computer using a known-good USB cable.
A lot of people end up using charge-only USB cables and it is very frustrating! So make sure you have a USB cable you know is good for data sync.
Option 1 - Load with UF2 Bootloader
This is by far the easiest way to load CircuitPython. However it requires your board has the UF2 bootloader installed. Some early boards do not (we hadn't written UF2 yet!) - in which case you can load using the built in ROM bootloader.
Still, try this first!
Try Launching UF2 Bootloader
Loading CircuitPython by drag-n-drop UF2 bootloader is the easier way and we recommend it. If you have a MagTag where the front of the board is black, your MagTag came with UF2 already on it.
Launch UF2 by double-clicking the Reset button (the one next to the USB C port). You may have to try a few times to get the timing right.
If you're using Windows and you get an error at the end of the file copy that says Error from the file copy, Error 0x800701B1: A device which does not exist was specified. You can ignore this error, the bootloader sometimes disconnects without telling Windows, the install completed just fine and you can continue. If its really annoying, you can also upgrade the bootloader (the latest version of the UF2 bootloader fixes this warning)
Your board should auto-reset into CircuitPython, or you may need to press reset. A CIRCUITPY drive will appear. You're done! Go to the next pages.
Option 2 - Use esptool to load BIN file
If you have an original MagTag with while soldermask on the front, we didn't have UF2 written for the ESP32S2 yet so it will not come with the UF2 bootloader.
You can upload with esptool to the ROM (hardware) bootloader instead!
Follow the initial steps found in the Run esptool and check connection section of the ROM Bootloader page to verify your environment is set up, your board is successfully connected, and which port it's using.
In the final command to write a binary file to the board, replace the port with your port, and replace "firmware.bin" with the the file you downloaded above.
The output should look something like the output in the image.
Press reset to exit the bootloader.
Your CIRCUITPY drive should appear!
You're all set! Go to the next pages.
Option 3 - Use Chrome Browser To Upload BIN file
If for some reason you cannot get esptool to run, you can always try using the Chrome-browser version of esptool we have written. This is handy if you don't have Python on your computer, or something is really weird with your setup that makes esptool not run (which happens sometimes and isn't worth debugging!) You can follow along on the Web Serial ESPTool page and either load the UF2 bootloader and then come back to Option 1 on this page, or you can download the CircuitPython BIN file directly using the tool in the same manner as the bootloader.
Page last edited January 28, 2026
Text editor powered by tinymce.
Code the MagTag
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 MagTag via a known good USB power+data cable. A new flash drive should show up on your computer as CIRCUITPY.
Drag the lib directory and the magtag_code.py file onto your MagTag board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
# SPDX-FileCopyrightText: 2026 John Park for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
MagTag ePaper Camera Display - receives and displays images from Adafruit IO
Optimized for battery operation with deep sleep between checks
"""
import binascii
import gc
import os
import ssl
import time
import traceback
from io import BytesIO
import adafruit_imageload
import adafruit_requests
import alarm
import displayio
import socketpool
import wifi
from adafruit_io.adafruit_io import IO_HTTP
from adafruit_magtag.magtag import MagTag
# ============ USER CONFIGURATION ============
# Sleep interval in seconds between image checks
# Examples: 60 = 1 min, 300 = 5 min, 900 = 15 min, 3600 = 1 hour
SLEEP_INTERVAL = 300 # Start with 300 seconds for testing
# ============================================
print("MagTag ePaper Camera Display (Deep Sleep Mode)")
print(f"Sleep interval: {SLEEP_INTERVAL} seconds")
# Check if we're waking from deep sleep
if alarm.wake_alarm:
print("Woke from deep sleep")
else:
print("Cold boot - clearing sleep memory")
# Clear sleep memory on cold boot
alarm.sleep_memory[0:100] = b"\x00" * 100
# Initialize MagTag
magtag = MagTag()
# Set NeoPixels brightness and turn amber on power up
magtag.peripherals.neopixels.brightness = 0.1
magtag.peripherals.neopixels.fill(0x202000) # Amber
# WiFi credentials from settings.toml
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')}!")
# Turn cyan when connected to WiFi
magtag.peripherals.neopixels.fill(0x002020) # Cyan
time.sleep(0.3)
# Set up Adafruit IO
pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())
aio_username = os.getenv("ADAFRUIT_AIO_USERNAME")
aio_key = os.getenv("ADAFRUIT_AIO_KEY")
io_client = IO_HTTP(aio_username, aio_key, requests)
# Get the camera feed
feed_camera = io_client.get_feed("epapercam")
# Pre-calculate display dimensions
DISPLAY_WIDTH = magtag.graphics.display.width # 296
DISPLAY_HEIGHT = magtag.graphics.display.height # 128
def get_last_timestamp():
"""Retrieve the last image timestamp from sleep memory."""
try:
# Read 100 bytes from sleep memory (enough for a timestamp string)
stored = bytes(alarm.sleep_memory[0:100]).decode("utf-8").strip("\x00")
if stored:
print(f"Last stored timestamp: {stored}")
return stored
return None
except (UnicodeDecodeError, AttributeError):
return None
def save_timestamp(timestamp):
"""Save the current image timestamp to sleep memory."""
try:
# Encode timestamp as UTF-8 bytes and store in sleep memory
timestamp_bytes = timestamp.encode("utf-8")
# Pad with null bytes to fill 100 bytes
padded = timestamp_bytes + b"\x00" * (100 - len(timestamp_bytes))
alarm.sleep_memory[0:100] = padded[:100]
print(f"Saved timestamp to memory: {timestamp}")
except Exception as e: # pylint: disable=broad-except
print(f"Error saving timestamp: {e}")
def scale_and_crop_image(src_bitmap, src_y_offset, scale, img_palette):
"""Scale and crop the source bitmap to display size."""
# Create display-sized bitmap
display_bitmap = displayio.Bitmap(DISPLAY_WIDTH, DISPLAY_HEIGHT, len(img_palette))
# Scale and crop the image
for y in range(DISPLAY_HEIGHT):
src_y = int((y + src_y_offset) / scale)
if src_y >= src_bitmap.height:
src_y = src_bitmap.height - 1
for x in range(DISPLAY_WIDTH):
src_x = int(x / scale)
if src_x >= src_bitmap.width:
src_x = src_bitmap.width - 1
display_bitmap[x, y] = src_bitmap[src_x, src_y]
return display_bitmap
def fetch_and_display_epaper_cam_image(data):
"""Display ePaper Camera image from already-fetched Adafruit IO data."""
try:
# Force garbage collection before processing
gc.collect()
print(f"Free memory before processing: {gc.mem_free()} bytes")
# Turn on NeoPixels to indicate processing
magtag.peripherals.neopixels.fill(0x202020) # Dim white
print("Processing image...")
base64_image = data["value"]
print(f"Received image data: {len(base64_image)} bytes (base64)")
# Decode base64 to get GIF binary data
gif_data = binascii.a2b_base64(base64_image)
print(f"Decoded GIF: {len(gif_data)} bytes")
# Free base64_image
base64_image = None
gc.collect()
# Create a BytesIO wrapper for the GIF data
gif_stream = BytesIO(gif_data)
# Load with adafruit_imageload (supports GIF)
bitmap, palette = adafruit_imageload.load(
gif_stream, bitmap=displayio.Bitmap, palette=displayio.Palette
)
print(f"Loaded image: {bitmap.width}x{bitmap.height}")
# Free gif_data and gif_stream
gif_data = None
gif_stream = None
gc.collect()
# Calculate scale factor to fill display width
scale = DISPLAY_WIDTH / bitmap.width
scaled_height = int(bitmap.height * scale)
print(f"Scaling to: {DISPLAY_WIDTH}x{scaled_height} (scale={scale:.2f})")
# Calculate vertical offset for centering (if scaled image is taller than display)
src_y_offset = (
(scaled_height - DISPLAY_HEIGHT) // 2
if scaled_height > DISPLAY_HEIGHT
else 0
)
# Scale and crop the image using helper function
display_bitmap = scale_and_crop_image(bitmap, src_y_offset, scale, palette)
# Free original bitmap after scaling
bitmap = None
gc.collect()
# Create a TileGrid for display
tile_grid = displayio.TileGrid(display_bitmap, pixel_shader=palette, x=0, y=0)
# Create a group and add the tile grid
group = displayio.Group()
group.append(tile_grid)
# Display on the MagTag
magtag.graphics.display.root_group = group
print("Refreshing display...")
magtag.graphics.display.refresh()
print("Image displayed on MagTag!")
# Turn off NeoPixels after successful display
magtag.peripherals.neopixels.fill(0x000000)
# Final garbage collection
gc.collect()
print(f"Free memory after processing: {gc.mem_free()} bytes")
return True # Success
except Exception as err: # pylint: disable=broad-except
# Turn NeoPixels red to indicate error
magtag.peripherals.neopixels.fill(0x200000) # Dim red
time.sleep(2)
magtag.peripherals.neopixels.fill(0x000000)
print(f"Error: {err}")
traceback.print_exception(type(err), err, err.__traceback__)
# Garbage collection on error
gc.collect()
return False # Failure
# Main execution - check for new image then deep sleep
try:
# Turn green while checking for new images
magtag.peripherals.neopixels.fill(0x002000) # Green
# Get last known timestamp
last_timestamp = get_last_timestamp()
# Fetch metadata to check timestamp
print("Checking for new image...")
feed_data = io_client.receive_data(feed_camera["key"])
current_timestamp = feed_data.get("updated_at")
print(f"Current image timestamp: {current_timestamp}")
# Compare timestamps
if current_timestamp != last_timestamp:
print("New image detected! Downloading and displaying...")
success = fetch_and_display_epaper_cam_image(feed_data)
if success:
# Save the new timestamp
save_timestamp(current_timestamp)
else:
print("Display failed - not updating stored timestamp")
else:
print("No new image - skipping download to save battery")
# Turn off green LED since we're not doing anything
magtag.peripherals.neopixels.fill(0x000000)
print(f"Going to deep sleep for {SLEEP_INTERVAL} seconds...")
time.sleep(1) # Brief delay to see the message
# Create a time alarm for waking up
time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + SLEEP_INTERVAL)
# Enter deep sleep - this will restart the program when it wakes
alarm.exit_and_deep_sleep_until_alarms(time_alarm)
except Exception as main_err: # pylint: disable=broad-except
print(f"Error in main execution: {main_err}")
traceback.print_exception(type(main_err), main_err, main_err.__traceback__)
# Even on error, go to sleep to preserve battery
magtag.peripherals.neopixels.fill(0x200000) # Red error indicator
time.sleep(2)
magtag.peripherals.neopixels.fill(0x000000)
print(f"Error - sleeping for {SLEEP_INTERVAL} seconds...")
time.sleep(1)
time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + SLEEP_INTERVAL)
alarm.exit_and_deep_sleep_until_alarms(time_alarm)
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.
How It Works
The MagTag acts as a wireless picture frame, automatically checking Adafruit IO for new photos and displaying them on its e-ink screen. It's designed to run on battery power for weeks or even months by using deep sleep between checks.
Configuration and Power Management
At the top of the code, you'll find the sleep interval setting:
SLEEP_INTERVAL = 300 # 5 minutes in seconds
This determines how often the MagTag wakes up to check for new images. Start with 300 seconds (5 minutes) for testing, then increase it to 900 (15 minutes) or 3600 (1 hour) for longer battery life. Longer intervals mean better battery life but slower image updates.
Sleep Memory and Wake Detection
When the MagTag wakes up, it needs to know whether this is a fresh boot or a wake from deep sleep:
if alarm.wake_alarm:
print("Woke from deep sleep")
else:
print("Cold boot - clearing sleep memory")
alarm.sleep_memory[0:100] = b"\x00" * 100
The ESP32 has a special 256-byte "sleep memory" that persists through deep sleep but not through power loss. We use the first 100 bytes to store the timestamp of the last image we displayed. On a cold boot (like when you first plug in the battery), we clear this memory so the MagTag will display whatever image is currently in the feed.
NeoPixel Status Indicators
The four NeoPixels on the MagTag act as status indicators, giving you visual feedback about what's happening:
- Amber: Power-on (brief)
- Cyan: Connected to WiFi
- Green: Checking Adafruit IO for new images
- White: Downloading and processing an image
- Red: An error occurred
- Off: In deep sleep (saving battery)
magtag.peripherals.neopixels.fill(0x002000) # Green while checking
These dim colors (0x002000 instead of 0x00FF00) save power while still being visible.
Connecting and Setup
After showing the amber "power-on" indicator, the MagTag connects to WiFi using credentials from your settings.toml file:
wifi.radio.connect(
os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")
)
It then creates a connection to Adafruit IO and gets information about the epapercam feed where images are stored:
io_client = IO_HTTP(aio_username, aio_key, requests)
feed_camera = io_client.get_feed("epapercam")
Update Check
To help with battery life, instead of downloading every time it wakes up, the MagTag first checks if there's actually a new image:
last_timestamp = get_last_timestamp() # From sleep memory
feed_data = io_client.receive_data(feed_camera["key"])
current_timestamp = feed_data.get("updated_at")
if current_timestamp != last_timestamp:
# New image! Download and display it
else:
# Same image, skip download to save battery
The get_last_timestamp() function reads the stored timestamp from sleep memory:
stored = bytes(alarm.sleep_memory[0:100]).decode("utf-8").strip("\x00")
If the timestamps match, the MagTag skips the download entirely, turns off the LEDs, and goes back to sleep. This saves significant battery power since most wake cycles will find no new image.
Image Processing Pipeline
When a new image is detected, fetch_and_display_epaper_cam_image() handles the download and display.
Step 1: Download and Decode
The image arrives as base64-encoded text (the same format the MEMENTO sent). We decode it back to binary GIF data:
gif_data = binascii.a2b_base64(base64_image)
Step 2: Load the GIF
Using adafruit_imageload, we convert the GIF data into a bitmap and color palette that CircuitPython can work with:
gif_stream = BytesIO(gif_data) bitmap, palette = adafruit_imageload.load(gif_stream)
Step 3: Scaling
The MEMENTO captures 240x240 pixel images, but the MagTag display is 296x128 pixels. We need to scale and crop the image to fit. First, we calculate the scale factor to fill the width:
scale = DISPLAY_WIDTH / bitmap.width # 296 / 240 = 1.23 scaled_height = int(bitmap.height * scale) # 296 pixels tall
Step 4: Centering and Cropping
Since the scaled image (296x296) is taller than the display (296x128), we crop it vertically and center it:
src_y_offset = (scaled_height - DISPLAY_HEIGHT) // 2
This offset tells us how many pixels to skip from the top of the scaled image to show the center portion.
Step 5: Pixel-by-Pixel Scaling
The scale_and_crop_image() helper function does the actual scaling using nearest-neighbor interpolation. For each pixel on the display, it figures out which pixel from the source image to copy:
for y in range(DISPLAY_HEIGHT):
src_y = int((y + src_y_offset) / scale)
for x in range(DISPLAY_WIDTH):
src_x = int(x / scale)
display_bitmap[x, y] = src_bitmap[src_x, src_y]
Step 6: Display
Finally, we create a TileGrid with the scaled bitmap and display it:
tile_grid = displayio.TileGrid(display_bitmap, pixel_shader=palette, x=0, y=0) group = displayio.Group() group.append(tile_grid) magtag.graphics.display.root_group = group magtag.graphics.display.refresh()
The e-ink display refresh takes about 2-3 seconds and creates that characteristic "flash" as the screen updates.
Saving the Timestamp
After successfully displaying the new image, we save its timestamp to sleep memory so we won't download it again:
timestamp_bytes = timestamp.encode("utf-8")
padded = timestamp_bytes + b"\x00" * (100 - len(timestamp_bytes))
alarm.sleep_memory[0:100] = padded[:100]
Deep Sleep
The final step is entering deep sleep, which reduces power consumption from ~80mA down to ~40µA (microamps!):
time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + SLEEP_INTERVAL) alarm.exit_and_deep_sleep_until_alarms(time_alarm)
When the time alarm triggers, the ESP32 wakes up and restarts the entire program from the beginning. This is why we check alarm.wake_alarm at the start - it tells us this is a wake from sleep, not a fresh boot.
Memory Management for Stability
Just like the MEMENTO code, the MagTag explicitly frees memory after each major operation:
base64_image = None gc.collect()
This ensures the device can run reliably for weeks or months without running out of RAM.
Error Handling
If anything goes wrong (WiFi issues, corrupted image data, Adafruit IO problems), the code catches the error, shows a red LED briefly, and still goes to deep sleep. This ensures that even if one wake cycle fails, the MagTag will try again after the next sleep interval:
except Exception as main_err:
magtag.peripherals.neopixels.fill(0x200000) # Red
# Still go to sleep to preserve battery
alarm.exit_and_deep_sleep_until_alarms(time_alarm)
Page last edited January 28, 2026
Text editor powered by tinymce.
Use It
Here's how to take pictures with your ePaper Camera. First, turn on the MEMENTO and the MagTag -- after a moment they will connect to the WiFi based on the credentials you entered in the settings.toml files.
Snap a Pic
Press the UP button on the MEMENTO to cycle between NeoPixel ring levels. Then, snap a photo.
Auto Upload
The MEMENTO will automatically dither and then upload the photo to your Adafruit IO epapercam feed. After about 20-30 seconds you'll see the full frame image appear in the feed.
Auto Download
The next time the MagTag comes out of deep sleep to check for a new image, it'll detect the new photo, download it, crop it to fit, and then display it on the eInk display.
You can use the magnetic feet to mount it on your fridge or locker or other metal surface.
Page last edited January 28, 2026
Text editor powered by tinymce.





