Make a custom shadow box with multiple paper layers, stacked within a frame lined with NeoPixels LED lights. The light shining between the layers creates a gorgeous colorful depth to your artwork.

This tutorial takes this art form a step further with the addition of a WiFi-enabled MagTag E-Ink display. The CircuitPython code connects to the internet over WiFi and receives a real time clock / calendar feed for your location and automatically sets the clock on the MagTag display. 

The real magic happens with the NeoPixel visualization code. At sunrise each day your pixels will light up with a beautiful sunrise color palette. When day breaks, your pixels will transition to a lovely daytime color palette, and then fade through a sunset palette into a starry night as the sun sets.

It's the perfect accessory for a Smart home - a custom, handmade piece of artwork that suits the room at any time of day.

The electronics build for this project is very easy - just plug the NeoPixel strip into the MagTag, plug into power, and you're done.

The software setup is just a little bit trickier. You'll need to install CircuitPython and a few libraries, then do a little customization to add your location and WiFi info. You can keep the color palettes as-is or dig in to the code and program your own beautiful colors.

The hardest part of this project is the physical shadow box build. You'll need a vinyl cutter (or a utility knife and a LOT of skill and patience), a laminating machine ($25 on Amazon, and oh so useful), and a shadow box frame.

This tutorial includes a free downloadable shadow box design, as well as some tips on how to create your own. You can also look online at the wide variety of shadow box designs available, and customize them to accommodate the MagTag and NeoPixel strip. The possibilities are endless.

Parts

Angled shot of Adafruit MagTag development board with ESP32-S2 and E-Ink display.
The Adafruit MagTag combines the new ESP32-S2 wireless module and a 2.9" grayscale E-Ink display to make a low-power IoT display that can show data on its screen even when power...
Out of Stock
Adafruit NeoPixel LED Strip with 3-pin JST Connector lit up rainbow
Plug in and glow, this Adafruit NeoPixel LED Strip with JST PH Connector has 30 total LEDs and is 1 meter long, in classy Adafruit...
$12.50
In Stock

Power your pixels & magtag with a USB C cable. 

1 x USB C Cable
1m USB C Cable

Once your project is programmed, you can plug it into the wall directly with this USB C power supply. This cable will work great with a Smart Plug in case you want to add home automation control to your artwork.

1 x USB C Power Suppply
Power Supply 5.1V 3A with USB C - 1.5 meter long

You'll Also Need

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.

Set Up CircuitPython

Follow the steps to get CircuitPython installed on your MagTag.

Click the link above and download the latest .BIN and .UF2 file

(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 the UF2 bootloader is installed, you will see a new disk drive appear called MAGTAGBOOT

Copy the UF2 file you downloaded at the first step of this tutorial onto the MAGTAGBOOT drive

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.

One of the great things about the ESP32 is the built-in WiFi capabilities. This page covers the basics of getting connected using CircuitPython.

The first thing you need to do is update your code.py to the following. Click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, and copy the entire lib folder and the code.py file to your CIRCUITPY drive.

# SPDX-FileCopyrightText: 2020 Brent Rubell for Adafruit Industries
#
# SPDX-License-Identifier: MIT

import os
import ipaddress
import ssl
import wifi
import socketpool
import adafruit_requests

# URLs to fetch from
TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html"
JSON_QUOTES_URL = "https://www.adafruit.com/api/quotes.php"
JSON_STARS_URL = "https://api.github.com/repos/adafruit/circuitpython"

print("ESP32-S2 WebClient Test")

print(f"My MAC address: {[hex(i) for i in wifi.radio.mac_address]}")

print("Available WiFi networks:")
for network in wifi.radio.start_scanning_networks():
    print("\t%s\t\tRSSI: %d\tChannel: %d" % (str(network.ssid, "utf-8"),
                                             network.rssi, network.channel))
wifi.radio.stop_scanning_networks()

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')}")
print(f"My IP address: {wifi.radio.ipv4_address}")

ping_ip = ipaddress.IPv4Address("8.8.8.8")
ping = wifi.radio.ping(ip=ping_ip)

# retry once if timed out
if ping is None:
    ping = wifi.radio.ping(ip=ping_ip)

if ping is None:
    print("Couldn't ping 'google.com' successfully")
else:
    # convert s to ms
    print(f"Pinging 'google.com' took: {ping * 1000} ms")

pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())

print(f"Fetching text from {TEXT_URL}")
response = requests.get(TEXT_URL)
print("-" * 40)
print(response.text)
print("-" * 40)

print(f"Fetching json from {JSON_QUOTES_URL}")
response = requests.get(JSON_QUOTES_URL)
print("-" * 40)
print(response.json())
print("-" * 40)

print()

print(f"Fetching and parsing json from {JSON_STARS_URL}")
response = requests.get(JSON_STARS_URL)
print("-" * 40)
print(f"CircuitPython GitHub Stars: {response.json()['stargazers_count']}")
print("-" * 40)

print("Done")

Your CIRCUITPY drive should resemble the following.

CIRCUITPY

To get connected, the next thing you need to do is update the settings.toml file.

The settings.toml File

We expect people to share tons of projects as they build CircuitPython WiFi widgets. What we want to avoid is people accidentally sharing their passwords or secret tokens and API keys. So, we designed all our examples to use a settings.toml file, that is on your CIRCUITPY drive, to hold secret/private/custom data. That way you can share your main project without worrying about accidentally sharing private stuff.

If you have a fresh install of CircuitPython on your board, the initial settings.toml file on your CIRCUITPY drive is empty.

To get started, you can update the settings.toml on your CIRCUITPY drive to contain the following code.

# SPDX-FileCopyrightText: 2023 Adafruit Industries
#
# SPDX-License-Identifier: MIT

# This is where you store the credentials necessary for your code.
# The associated demo only requires WiFi, but you can include any
# credentials here, such as Adafruit IO username and key, etc.
CIRCUITPY_WIFI_SSID = "your-wifi-ssid"
CIRCUITPY_WIFI_PASSWORD = "your-wifi-password"

This file should contain a series of Python variables, each assigned to a string. Each variable should describe what it represents (say wifi_ssid), followed by an (equals sign), followed by the data in the form of a Python string (such as "my-wifi-password" including the quote marks).

At a minimum you'll need to add/update your WiFi SSID and WiFi password, so do that now!

As you make projects you may need more tokens and keys, just add them one line at a time. See for example other tokens such as one for accessing GitHub or the Hackaday API. Other non-secret data like your timezone can also go here.

For the correct time zone string, look at http://worldtimeapi.org/timezones and remember that if your city is not listed, look for a city in the same time zone, for example Boston, New York, Philadelphia, Washington DC, and Miami are all on the same time as New York.

Of course, don't share your settings.toml - keep that out of GitHub, Discord or other project-sharing sites.

Don't share your settings.toml file! It has your passwords and API keys in it!

If you connect to the serial console, you should see something like the following:

In order, the example code...

Checks the ESP32's MAC address.

print(f"My MAC address: {[hex(i) for i in wifi.radio.mac_address]}")

Performs a scan of all access points and prints out the access point's name (SSID), signal strength (RSSI), and channel.

print("Available WiFi networks:")
for network in wifi.radio.start_scanning_networks():
    print("\t%s\t\tRSSI: %d\tChannel: %d" % (str(network.ssid, "utf-8"),
                                             network.rssi, network.channel))
wifi.radio.stop_scanning_networks()

Connects to the access point you defined in the settings.toml file, and prints out its local IP address.

print(f"Connecting to {os.getenv('WIFI_SSID')}")
wifi.radio.connect(os.getenv("WIFI_SSID"), os.getenv("WIFI_PASSWORD"))
print(f"Connected to {os.getenv('WIFI_SSID')}")
print(f"My IP address: {wifi.radio.ipv4_address}")

Attempts to ping a Google DNS server to test connectivity. If a ping fails, it returns None. Initial pings can sometimes fail for various reasons. So, if the initial ping is successful (is not None), it will print the echo speed in ms. If the initial ping fails, it will try one more time to ping, and then print the returned value. If the second ping fails, it will result in "Ping google.com: None ms" being printed to the serial console. Failure to ping does not always indicate a lack of connectivity, so the code will continue to run.

ping_ip = ipaddress.IPv4Address("8.8.8.8")
ping = wifi.radio.ping(ip=ping_ip) * 1000
if ping is not None:
    print(f"Ping google.com: {ping} ms")
else:
    ping = wifi.radio.ping(ip=ping_ip)
    print(f"Ping google.com: {ping} ms")

The code creates a socketpool using the wifi radio's available sockets. This is performed so we don't need to re-use sockets. Then, it initializes a a new instance of the requests interface - which makes getting data from the internet really really easy.

pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())

To read in plain-text from a web URL, call requests.get - you may pass in either a http, or a https url for SSL connectivity. 

print(f"Fetching text from {TEXT_URL}")
response = requests.get(TEXT_URL)
print("-" * 40)
print(response.text)
print("-" * 40)

Requests can also display a JSON-formatted response from a web URL using a call to requests.get

print(f"Fetching json from {JSON_QUOTES_URL}")
response = requests.get(JSON_QUOTES_URL)
print("-" * 40)
print(response.json())
print("-" * 40)

Finally, you can fetch and parse a JSON URL using requests.get. This code snippet obtains the stargazers_count field from a call to the GitHub API.

print(f"Fetching and parsing json from {JSON_STARS_URL}")
response = requests.get(JSON_STARS_URL)
print("-" * 40)
print(f"CircuitPython GitHub Stars: {response.json()['stargazers_count']}")
print("-" * 40)

OK you now have your ESP32 board set up with a proper settings.toml file and can connect over the Internet. If not, check that your settings.toml file has the right SSID and password and retrace your steps until you get the Internet connectivity working!

A very common need for projects is to know the current date and time. Especially when you want to deep sleep until an event, or you want to change your display based on what day, time, date, etc. it is

Determining the correct local time is really really hard. There are various time zones, Daylight Savings dates, leap seconds, etc. Trying to get NTP time and then back-calculating what the local time is, is extraordinarily hard on a microcontroller just isn't worth the effort and it will get out of sync as laws change anyways.

For that reason, we have the free adafruit.io time service. Free for anyone with a free adafruit.io account. You do need an account because we have to keep accidentally mis-programmed-board from overwhelming adafruit.io and lock them out temporarily. Again, it's free!

There are other services like WorldTimeAPI, but we don't use those for our guides because they are nice people and we don't want to accidentally overload their site. Also, there's a chance it may eventually go down or also require an account.

Step 1) Make an Adafruit account

It's free! Visit https://accounts.adafruit.com/ to register and make an account if you do not already have one

Step 2) Sign into Adafruit IO

Head over to io.adafruit.com and click Sign In to log into IO using your Adafruit account. It's free and fast to join.

Step 3) Get your Adafruit IO Key

Click on My Key in the top bar

adafruit_products_image.png
"My Key" has been replaced with a key-shaped icon!

You will get a popup with your Username and Key (In this screenshot, we've covered it with red blocks)

Go to your secrets.py file on your CIRCUITPY drive and add three lines for aio_username, aio_key and timezone so you get something like the following:

# This file is where you keep secret settings, passwords, and tokens!
# If you put them in the code you risk committing that info or sharing it

secrets = {
    'ssid' : 'home_wifi_network',
    'password' : 'wifi_password',
    'aio_username' : 'my_adafruit_io_username',
    'aio_key' : 'my_adafruit_io_key',
    'timezone' : "America/New_York", # http://worldtimeapi.org/timezones
    }

The timezone is optional, if you don't have that entry, adafruit.io will guess your timezone based on geographic IP address lookup. You can visit http://worldtimeapi.org/timezones to see all the time zones available (even though we do not use Worldtime for time-keeping, we do use the same time zone table).

Step 4) Upload Test Python Code

This code is like the Internet Test code from before, but this time it will connect to adafruit.io and get the local time

import ipaddress
import ssl
import wifi
import socketpool
import adafruit_requests
import secrets

# Get wifi details and more from a secrets.py file
try:
    from secrets import secrets
except ImportError:
    print("WiFi secrets are kept in secrets.py, please add them there!")
    raise

# Get our username, key and desired timezone
aio_username = secrets["aio_username"]
aio_key = secrets["aio_key"]
location = secrets.get("timezone", None)
TIME_URL = "https://io.adafruit.com/api/v2/%s/integrations/time/strftime?x-aio-key=%s&tz=%s" % (aio_username, aio_key, location)
TIME_URL += "&fmt=%25Y-%25m-%25d+%25H%3A%25M%3A%25S.%25L+%25j+%25u+%25z+%25Z"

print("ESP32-S2 Adafruit IO Time test")

print("My MAC addr:", [hex(i) for i in wifi.radio.mac_address])

print("Available WiFi networks:")
for network in wifi.radio.start_scanning_networks():
    print("\t%s\t\tRSSI: %d\tChannel: %d" % (str(network.ssid, "utf-8"),
            network.rssi, network.channel))
wifi.radio.stop_scanning_networks()

print("Connecting to %s"%secrets["ssid"])
wifi.radio.connect(secrets["ssid"], secrets["password"])
print("Connected to %s!"%secrets["ssid"])
print("My IP address is", wifi.radio.ipv4_address)

ipv4 = ipaddress.ip_address("8.8.4.4")
print("Ping google.com: %f ms" % wifi.radio.ping(ipv4))

pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())

print("Fetching text from", TIME_URL)
response = requests.get(TIME_URL)
print("-" * 40)
print(response.text)
print("-" * 40)

After running this, you will see something like the below text. We have blocked out the part with the secret username and key data!

Note at the end you will get the date, time, and your timezone! If so, you have correctly configured your secrets.py and can continue to the next steps!

Secrets File Setup

Open the secrets.py file on your CircuitPython device using Mu or your favorite text editor. If you don't have one, copy the generic one below. You're going to edit this file to enter your wifi and location info.

  • Change aio_username to your Adafruit IO username
  • Change aio_key to your Adafruit IO active key. Don't use the one in the example below, it's just a placeholder
  • Change timezone to your time zone. Find the correct one here.
  • Change latitude and longitude to your location. Get this info by looking up your house in Google Maps and right-clicking on that spot

Your secrets.py file should look something like this: 

# This file is where you keep secret settings, passwords, and tokens!
# If you put them in the code you risk committing that info or sharing it

secrets = {
    'ssid' : 'Wifi_Network',
    'password' : 'Wifi_Password',
    'timezone' : "America/Los_Angeles", # http://worldtimeapi.org/timezones
    'aio_username'  :  'Username',
    'aio_key' : '36e7bd431d4lwd9mx21x8b10d9be7a77d8d5',
    'latitude'  : 38.86965735043201, 
    'longitude' : -121.07998478906876,
	}

Installing Project Code

To use with CircuitPython, you need to first install a few libraries, into the lib folder on your CIRCUITPY drive. Then you need to update code.py with the example script.

Thankfully, we can do this in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, open the directory Shadow_Box/ and then click on the directory that matches the version of CircuitPython you're using and copy the contents of that directory to your CIRCUITPY drive.

Your CIRCUITPY drive should now look similar to the following image:

CIRCUITPY
# SPDX-FileCopyrightText: 2021 Erin St Blaine for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
Clock & sky colorbox for Adafruit MagTag: displays current time while
NeoPixels provide theme lighting for the time of day. Requires WiFi
internet access -- configure credentials in secrets.py. An Adafruit IO
user name and API key are also needed there, plus timezone and
geographic coords.
"""

# pylint: disable=import-error
import time
import json
import board
import neopixel
from adafruit_magtag.magtag import MagTag
import adafruit_fancyled.adafruit_fancyled as fancy

# UTC offset queries require some info from the secrets table...
try:
    from secrets import secrets
except ImportError:
    print('Please set up secrets.py with network credentials.')


# CONFIGURABLE SETTINGS ----------------------------------------------------

USE_AMPM_TIME = True # Set to False to use 24-hour time (e.g. 18:00)
NUM_LEDS = 22        # Length of NeoPixel strip
BRIGHTNESS = 0.9     # NeoPixel brightness: 0.0 (off) to 1.0 (max)
SPIN_TIME = 10 * 60  # Seconds for NeoPixels to complete one revolution
# Default spin time is 10 minutes. It should be very slow...imperceptible
# really...as there will be pauses when network activity is occurring.

DAY_PALETTE = [               # Daylight colors
    fancy.CRGB(0.5, 0, 1.0),  # Purplish blue
    fancy.CRGB(0, 0.5, 1.0),  # Blue
    fancy.CRGB(0, 0.5, 1.0),  # Blue
    0x1B90FF,                 # Cyan
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0x30FEF2,                 # Sky blue
    0x0C69FC,                 # Sky blue
    0x1A82FF,
    fancy.CRGB(0, 0.8, 0.8),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0.5, 0, 1.0),] # Purplish blue

NIGHT_PALETTE = [ # Starlight colors
    fancy.CRGB(0, 0, 1.0),
    fancy.CRGB(0, 0.2, 1.0),
    fancy.CRGB(0, 0.1, 1.0),
    fancy.CRGB(0, 0, 1.0),
    0x000000,
    0x000000,
    0x000000,
    fancy.CRGB(1.0, 1.0, 0.8),
    0x000000,
    fancy.CRGB(0.3, 0.3, 0.3),
    fancy.CRGB(0.2, 0.2, 0.2),
    fancy.CRGB(0.3, 0.3, 0.3),
    0x000000,
    0x000000,
    0x000000,
    0x000000]

HORIZON_PALETTE = [            # Dawn & dusk colors
    fancy.CHSV(0.8),           # Purple
    fancy.CHSV(1.0),           # Red
    fancy.CHSV(1.0),           # Red
    fancy.CRGB(1.0, 0.5, 0.0), # Orange
    fancy.CRGB(1.0, 0.5, 0.0), # Orange
    fancy.CRGB(1.0, 0.8, 0.0), # Yellow
    0xFFFFFF,                  # White
    fancy.CRGB(1.0, 0.8, 0.0), # Yellow
    fancy.CRGB(1.0, 0.5, 0.0)] # Orange


# SOME UTILITY FUNCTIONS ---------------------------------------------------

def hh_mm(time_struct, twelve_hour=True):
    """ Given a time.struct_time, return a string as H:MM or HH:MM, either
        12- or 24-hour style depending on twelve_hour flag.
    """
    postfix = ""
    if twelve_hour:
        if time_struct.tm_hour > 12:
            hour_string = str(time_struct.tm_hour - 12) # 13-23 -> 1-11 (pm)
            postfix = "pm"
        elif time_struct.tm_hour > 0:
            hour_string = str(time_struct.tm_hour) # 1-12
            postfix = "am"
        else:
            hour_string = '12' # 0 -> 12 (am)
            postfix = "pm"
    else:
        hour_string = '{hh:02d}'.format(hh=time_struct.tm_hour)
    return hour_string + ':{mm:02d}'.format(mm=time_struct.tm_min) + postfix

def parse_time(timestring):
    """ Given a string of the format YYYY-MM-DDTHH:MM:SS.SS-HH:MM (and
        optionally a DST flag), convert to and return a numeric value for
        elapsed seconds since midnight (date, UTC offset and/or decimal
        fractions of second are ignored).
    """
    date_time = timestring.split('T') # Separate into date and time
    hour_minute_second = date_time[1].split('+')[0].split('-')[0].split(':')
    return (int(hour_minute_second[0]) * 3600 +
            int(hour_minute_second[1]) * 60 +
            int(hour_minute_second[2].split('.')[0]))

def blend(palette1, palette2, weight2, offset):
    """ Given two FancyLED color palettes and a weighting (0.0 to 1.0) of
        the second palette, plus a positional offset (where 0.0 is the start
        of each palette), fill the NeoPixel strip with an interpolated blend
        of the two palettes.
    """
    weight2 = min(1.0, max(0.0, weight2)) # Constrain input to 0.0-1.0
    weight1 = 1.0 - weight2               # palette1 weight (inverse of #2)
    for i in range(NUM_LEDS):
        position = offset + i / NUM_LEDS
        color1 = fancy.palette_lookup(palette1, position)
        color2 = fancy.palette_lookup(palette2, position)
        # Blend the two colors based on weight1&2, run through gamma func:
        color = fancy.CRGB(
            color1[0] * weight1 + color2[0] * weight2,
            color1[1] * weight1 + color2[1] * weight2,
            color1[2] * weight1 + color2[2] * weight2)
        color = fancy.gamma_adjust(color, brightness=BRIGHTNESS)
        PIXELS[i] = color.pack()
    PIXELS.show()


# ONE-TIME INITIALIZATION --------------------------------------------------

MAGTAG = MagTag()

MAGTAG.graphics.set_background("/background.bmp")

MAGTAG.add_text(
    text_font="Lato-Regular-74.pcf",
    text_position=(MAGTAG.graphics.display.width // 2, 30),
    text_anchor_point=(0.5, 0),
    is_data=False,
)

# Declare NeoPixel object on pin D10 with NUM_LEDS pixels, no auto-write.
# Set brightness to max as we'll be using FancyLED's brightness control.
PIXELS = neopixel.NeoPixel(board.D10, NUM_LEDS, brightness=0.1,
                           auto_write=False)
PIXELS.show() # Off at start

LAST_SYNC = time.monotonic() - 5000 # Force initial clock sync
LAST_MINUTE = -1                    # Force initial display update
LAST_DAY = -1                       # Force initial sun query
SUNRISE = 6 * 60 * 60               # Sunrise @ 6am by default
SUNSET = 18 * 60 * 60               # Sunset @ 6pm by default
UTC_OFFSET = '+00:00'               # Gets updated along with time
SUN_FLAG = False                    # Triggered at midnight

# MAIN LOOP ----------------------------------------------------------------

while True:
    if (time.monotonic() - LAST_SYNC) > 3600: # Sync time once an hour
        MAGTAG.network.get_local_time()
        LAST_SYNC = time.monotonic()
        # Sun API requires a valid UTC offset. Adafruit IO's time API
        # offers this, but get_local_time() above (using AIO) doesn't
        # store it anywhere. I’ll put in a feature request for the
        # PortalBase library, but in the meantime this just makes a
        # second request to the time API asking for that one value.
        # Since time is synced only once per hour, the extra request
        # isn't particularly burdensome.
        try:
            RESPONSE = MAGTAG.network.requests.get(
                'https://io.adafruit.com/api/v2/%s/integrations/time/'
                'strftime?x-aio-key=%s&tz=%s' % (secrets.get('aio_username'),
                                                 secrets.get('aio_key'),
                                                 secrets.get('timezone')) +
                '&fmt=%25z')
            if RESPONSE.status_code == 200:
                # Arrives as sHHMM, convert to sHH:MM
                print(RESPONSE.text)
                UTC_OFFSET = RESPONSE.text[:3] + ':' + RESPONSE.text[-2:]
        except: # pylint: disable=bare-except
            # If query fails, prior value is kept until next query.
            # Only changes 2X a year anyway -- worst case, if these
            # events even align, is rise/set is off by an hour.
            pass

    NOW = time.localtime() # Current time (as time_struct)

    # If minute has changed, refresh display
    if LAST_MINUTE != NOW.tm_min:
        MAGTAG.set_text(hh_mm(NOW, USE_AMPM_TIME), index=0)
        LAST_MINUTE = NOW.tm_min

    # If day has changed (local midnight), set flag for later sun query
    # (it's not done at midnight, see below).
    if LAST_DAY != NOW.tm_mday:
        SUN_FLAG = True
        LAST_DAY = NOW.tm_mday

    # If the sun flag is set, and if the time is 3:05 am or thereabouts,
    # query the sun API for new rise and set times for today. It's done
    # this way (rather than at midnight) to allow for DST time jumps
    # (which occur at 2am) and slight clock drift (corrected hourly),
    # but still before dawn.
    if SUN_FLAG and (NOW.tm_hour * 60 + NOW.tm_min > 185):
        try:
            URL = ('https://api.met.no/weatherapi/sunrise/2.0/.json?'
                   'lat=%s&lon=%s&date=%s-%s-%s&offset=%s' %
                   (secrets.get('latitude'), secrets.get('longitude'),
                    str(NOW.tm_year), '{0:0>2}'.format(NOW.tm_mon),
                    '{0:0>2}'.format(NOW.tm_mday), UTC_OFFSET))
            print('Fetching sun data via', URL)
            FULL_DATA = json.loads(MAGTAG.network.fetch_data(URL))
            SUN_DATA = FULL_DATA['location']['time'][0]
            SUNRISE = parse_time(SUN_DATA['sunrise']['time'])
            SUNSET = parse_time(SUN_DATA['sunset']['time'])
        except: # pylint: disable=bare-except
            # If any part of the sun API query fails (whether network or
            # bad inputs), just repeat the old sun rise/set times and we'll
            # try again tomorrow. These only shift by seconds or minutes
            # daily, and the LEDs are just for mood, not like we're
            # launching a Mars rocket, errors here are not catastrophic.
            # Very worst case is a query error on a DST time change day,
            # in which case rise/set lights will be off by about an hour
            # until next successful query.
            pass
        SUN_FLAG = False # Pass or fail, don't query again until tomorrow

    # Convert NOW into elapsed seconds since midnight
    NOW = time.mktime(NOW) - time.mktime((NOW.tm_year, NOW.tm_mon,
                                          NOW.tm_mday, 0, 0, 0,
                                          NOW.tm_wday, NOW.tm_yday,
                                          NOW.tm_isdst))
    # Compare current time (in seconds since midnight) against sun rise/set
    # times and do color fades within +/- 30 minutes of each.
    if SUNRISE < NOW < SUNSET:                  # Day (ish)
        if NOW - SUNRISE < (30 * 60):           # Between sunrise & daylight
            PALETTE1, PALETTE2 = HORIZON_PALETTE, DAY_PALETTE
            INTERP = (NOW - SUNRISE) / (30 * 60)
        elif SUNSET - NOW < (30 * 60):          # Between daylight & sunset
            PALETTE1, PALETTE2 = HORIZON_PALETTE, DAY_PALETTE
            INTERP = (SUNSET - NOW) / (30 * 60)
        else:                                   # Full daylight
            PALETTE1 = PALETTE2 = DAY_PALETTE   # Day sky
            INTERP = 0.0                        # No fade
    else:                                       # Night (ish)
        if 0 < SUNRISE - NOW < (30 * 60):       # Between night & sunrise
            PALETTE1, PALETTE2 = HORIZON_PALETTE, NIGHT_PALETTE
            INTERP = (SUNRISE - NOW) / (30 * 60)
        elif 0 < NOW - SUNSET < (30 * 60):      # Between sunset & night
            PALETTE1, PALETTE2 = HORIZON_PALETTE, NIGHT_PALETTE
            INTERP = (NOW - SUNSET) / (30 * 60)
        else:                                   # Full night
            PALETTE1 = PALETTE2 = NIGHT_PALETTE # Night sky
            INTERP = 0.0                        # No fade

    # Update NeoPixels based on time of day
    blend(PALETTE1, PALETTE2, INTERP, time.monotonic() / SPIN_TIME)
If you're having difficulty running this example, it could be because your MagTag CircuitPython firmware or library needs to be upgraded! Please be sure to follow https://learn.adafruit.com/adafruit-magtag/circuitpython to install the latest CircuitPython firmware and then also replace/update ALL the MagTag-specific libraries mentioned here https://learn.adafruit.com/adafruit-magtag/circuitpython-libraries-2

Troubleshooting

If you're still having trouble, head over to our main MagTag guide for some troubleshooting tips.

Customizing your Code

There are a few configurable settings to look at near the top of the code:

# CONFIGURABLE SETTINGS ----------------------------------------------------

USE_AMPM_TIME = True # Set to False to use 24-hour time (e.g. 18:00)
NUM_LEDS = 22        # Length of NeoPixel strip
BRIGHTNESS = 0.9     # NeoPixel brightness: 0.0 (off) to 1.0 (max)
SPIN_TIME = 10 * 60  # Seconds for NeoPixels to complete one revolution
# Default spin time is 10 minutes. It should be very slow...imperceptible
# really...as there will be pauses when network activity is occurring.

Change NUM_LEDS to match the number of pixels in your strip. You can also adjust the global brightness here. Change SPIN_TIME to speed up or slow down the animation speed.

Color Palettes

There are three color palettes in the code: a daytime palette, a night time palette, and a horizon palette (for sunrise and sunset). You can customize these to your heart's content. FancyLED makes it really easy to create custom palettes. You can add as many or as few colors as you like, and rearrange the order to suit your design. Learn more about FancyLED here.

DAY_PALETTE = [               # Daylight colors
    fancy.CRGB(0.5, 0, 1.0),  # Purplish blue
    fancy.CRGB(0, 0.5, 1.0),  # Blue
    fancy.CRGB(0, 0.5, 1.0),  # Blue
    0x1B90FF,                 # Cyan
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0xFFEA0A,                 # Yellow
    0x30FEF2,                 # Sky blue
    0x0C69FC,                 # Sky blue
    0x1A82FF,
    fancy.CRGB(0, 0.8, 0.8),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0, 0.8, 0.2),  # Green
    fancy.CRGB(0.5, 0, 1.0),] # Purplish blue

NIGHT_PALETTE = [ # Starlight colors
    fancy.CRGB(0, 0, 1.0),
    fancy.CRGB(0, 0.2, 1.0),
    fancy.CRGB(0, 0.1, 1.0),
    fancy.CRGB(0, 0, 1.0),
    0x000000,
    0x000000,
    0x000000,
    fancy.CRGB(1.0, 1.0, 0.8),
    0x000000,
    fancy.CRGB(0.3, 0.3, 0.3),
    fancy.CRGB(0.2, 0.2, 0.2),
    fancy.CRGB(0.3, 0.3, 0.3),
    0x000000,
    0x000000,
    0x000000,
    0x000000]

HORIZON_PALETTE = [            # Dawn & dusk colors
    fancy.CHSV(0.8),           # Purple
    fancy.CHSV(1.0),           # Red
    fancy.CHSV(1.0),           # Red
    fancy.CRGB(1.0, 0.5, 0.0), # Orange
    fancy.CRGB(1.0, 0.5, 0.0), # Orange
    fancy.CRGB(1.0, 0.8, 0.0), # Yellow
    0xFFFFFF,                  # White
    fancy.CRGB(1.0, 0.8, 0.0), # Yellow
    fancy.CRGB(1.0, 0.5, 0.0)] # Orange

There are a lot of design options available for your shadow box. I've included the files I used for mine as a free download, and I'll give some tips about how to design your own shadow box from photographs or clip art using Adobe Illustrator.

You can also create your design directly in Cricut Design Space from uploaded images or from their extensive clip art library if you don't have access to fancy design software like Illustrator.

There are also dozens (if not hundreds) of fantastic shadow box designs available on Etsy. Get a digital download from an amazing designer for just a few bucks, and customize it to your heart's content. You'll also be supporting independent artists with your extra dollars, which is always worthwhile. Go wild.

Measurements

Take your shadow box frame apart and carefully measure the inside diameter. This generally won't be the same as the measurement on the label. Mine is 7 5/8.

Insert your LED strip and push it into all the corners evenly. Trim any extra lights off, cutting carefully through the copper pads.

Measure again, this time measuring the distance inside your LED strip. If you're using the same LEDs as me, this measurement will probably come out about 1/4" smaller than your first measurement. I got 7 3/8 for this one.

Composition

I found 6-7 layers to be about the right number to match with the depth of the LED strip.

The first layer is a black frame stuck to the inside of the glass that's just wide enough to cover up the NeoPixel strip, so no light bleeds directly into the viewer's eyes.

My design has a focal point of a kayaker in the foreground. There is a window-frame layer in front of the kayaker to give a sense that the viewer is looking at this scene from far away.

Behind the kayaker is a lake edge with trees reflected, and behind that is a layer with more trees and a mountain in the distance.

I finished it off with a starry sky and a solid back layer, to reflect any light back into the viewer's eyes through the stars.

I started with a high-contrast image for each layer. I upped the contrast a little more in Photoshop using the Levels tool, until the lights and darks really stood out from each other.

I opened the image in Adobe Illustrator and selected it, then went to Object > Image Trace to turn it into vector artwork.

This took a few tries, playing around with the contrast and the image crop, before I was happy with the result. You'll save yourself some time if you crop your original image to be the same aspect ratio as your shadow box.

Illustrator will make a whole lot of layers. I went into the Layers palette and deleted everything extraneous so I was left with only the black mountain and trees layer.

I used the Rectangle tool to make a 17 point rectangle around the entire artboard. I made sure the stroke was on the inside of the rectangle, and put it on its own layer so I could apply it to each of my layers individually. 

This part will be hidden behind the front frame so it won't be seen, but it will help tremendously with assembly and spacing, especially for layers that don't go all the way to the edges.

Any areas in white will be cut out by the Cricut and appear to recede in the design. Any areas in black will stay as solid layers.

This is important to get your head around if you want to create a successful design. Black areas that aren't connected to anything (i.e. if the kayaker was floating in the middle of the lake) are going to be more difficult to attach and line up. 

I created the simpler layers, like the starry sky and the window frame, directly in Illustrator using the rectangle tools and star tool. The star tool is pretty cool - you can change the number of points on the stars for variety and interest.

I finished up by adding a thicker (35pt) stroke rectangle for the very front frame, to block out the NeoPixel strip from bleeding around the edges.

Change the visibility of your layers to view them one by one, and export each layer as a separate .png file for upload into the Cricut Design Space.

Why Not Use SVG?

SVG files are compatible with the Cricut Design Space, and are better for some uses in that they are vector art and therefore lossless. However, they're more of a hassle to use with Illustrator. Hiding a layer in Illustrator does not eliminate it from the SVG file, so you'd need to save 6 different Illustrator files to get a clean export. 

The Cricut does a great job with .png, especially at this small size, so it's really easier for our purposes.

Upload Images

Upload each of the layer files to Cricut Design space. Choose "simple," then on the next screen click any white space areas to turn them transparent. Click "continue," then choose "save as cut image" on the third screen.

Since I built my star layer from scratch in Illustrator, it didn't export with transparency. I could go back into Illustrator and mess around with the paths, but it's sometimes easier to just edit in the Cricut design space. Click each star to turn it transparent.

Select all your layers and insert them into a new design. 

Resize Images

I like to change the color from black to something else to make my layers easier to see. You can do this with the color selector in the toolbar. I left one layer black: the very front frame image. Since I'm cutting it in a different color, I'll leave it a different color in Design Space.

Next, select all your layers and go to Align > Center. We need to resize them to fit our dimensions and this is easiest if we resize them all at once. If they aren't perfectly aligned, we'll get inaccurate measurements so don't skip this step.

Find the front frame layer. Resize this layer to the first measurement you took: the full width of the inside frame. You may need to click the lock icon to adjust both height and width to be just right. 

Hide the frame layer for now, and select ALL the remaining layers at once. Resize them to match your second measurement: the size of the inside frame with the NeoPixels in place.

Add MagTag Window

Once your layers are perfectly sized, we'll add a window for the MagTag. It's better to do this in Design Space rather than earlier in the design process, because the MagTag screen window always needs to be 2.7" x 1.1" no matter what size your shadow box frame is. Adding it in Design Space gives you the ability to resize your design without resizing the MagTag window.

Drag a rectangle from the shape tool into your workspace. Resize it so it's 2.7" wide and 1.1" high. Align it with your design, not too close to the edge. Remember the MagTag board itself is larger than the screen area we want to show, so make sure the whole board will fit.

My window is already placed in a transparent / cut out area for my front 4 layers, so I only need to cut the window into my back 2 layers. You can hide the other layers to keep from getting confused.

Select both the window and the first layer you want to cut and click on the "slice" tool. Repeat with any other layers you want to cut through - you have to do them one at a time.

Delete the original rectangle and any other "artifact" layers that result.

Custom Material Settings

Next, we'll prepare our material settings. I couldn't find the perfect pre-programmed setting for this material so after a bunch of experimentation, I made my own. 

Inside the menu at the upper left corner you'll find a link for "Manage Custom Materials". Set up a new material called "Laminated Paper" and choose "Fine Point Blade", set the pressure to 350, and choose 5x passes. Then click the star icon next to your new material to add it to your "favorites" list, so it's easy to find later on.

Make It

Make sure all the layers you want to cut are visible. Click the "Make It" button. 

Since my frame layer is a different color than my other layers, it prints on its own mat. How convenient!

It's very important to remember to click "edit" for each of the layers and align the designs so they line up with your actual material. The laminated paper has a bit of clear laminate along the edges. I don't want that part in my design so I dragged each layer in toward the middle of the mat just a bit so it cuts only from the white paper in the center of my material.

Choose your Laminated Paper materials setting and set the pressure to "more". Press the laminated paper down firmly onto your Strong Grip sticky mat, and get cutting.

Fire up your laminating machine and laminate a sheet of paper for each layer in your design. Laminate a few extras too, just in case something goes wrong during the cutting. 

I used regular printer paper with 5 mil laminating pouches. Feel free to play around with different types of paper, but be sure whatever you're using is a bit translucent - you should be able to see light through it if you hold it up to the window. This will ensure your layers all glow nicely.

You'll also want one completely opaque sheet for the frame layer in the very front, to block any light leakage from the NeoPixel strip along the sides. You can use laminated black paper for this if it's handy. 

My black paper wasn't quite opaque enough, so I ended up using black sticky-back vinyl (the kind these cutting machines are designed for). I was able to stick the vinyl directly to the glass front of my frame so it won't move around and let any light leak out.

Cut all the other layers using a strong grip mat. You'll find more instructions and cutting tips on the Design page.

If needed, trim the corners off your layers with a pair of scissors. I found this really helped the layers to sit flat inside the frame.

Place the first white layer directly down onto the frame layer. Don't attach it to anything yet. We'll build up the other layers on top of this one and it'll be easier to adjust them all later if we keep things removable.

Stick a 3d glue dot down in each corner, making sure they don't show through the front of the design. These dots will function as both adhesive and spacers between the layers. I found that stretching and rolling the glue dots before sticking them down gave me the height and profile I wanted.

Stack the layers up one at a time, using the zots to space them apart. Your goal is to build up all the layers to be about the same height as the LED strip, with the final layer behind all the lights.

Test the placement as you go. I found that adding a few more spacers between the layers in the middle of the design really gave my shadow box the depth I was looking for.

When all the layers are piled up to your satisfaction, attach the MagTag to the back of the frame so it shows through the window in your design. Stick it on with a handful of zots or with magnets to hold it in place through the last layer.

This guide was first published on Mar 03, 2021. It was last updated on Dec 08, 2023.