Building Stuff - Studium Lentum (Adabox017)

As I mentioned a couple of months ago, I subscribed to Adafruit's Adabox subscription service and I received my first box last week!

The closed box.

Lovely tissue paper

The packaging is very beautiful. The theme of this box is "Good Fortune Ahead," so the art card visible through the letters of the box feature zodiac signs. Inside of the box were the following goodies:

The MagTag is essentially a wifi-enabled board with an e-ink display on the front. There are four LED lights mounted perpendicularly to the display. The preloaded project featured a horoscope program that fetches fortunes from either the internet or a json file.

I decided to try to make a flashcard quiz program out of mine. I originally wanted to make such a program for Japanese, but after playing around with font files for a while, I discovered that the necessary BDF font file for displaying hiragana, katakana, and kanji would exceed the storage available onboard the MagTag. Pocketing that idea for a later project, I switched to making a Latin quiz program instead.

When I was desperately searching for employment 6-7 years ago, I worked on a number of projects to try to show off my tech skills and teaching skills together. One of these was an interactive Latin textbook that I didn't make too much progress on. I copied a couple of the graphics I had made for that and converted them into 4-color greyscale. The site was going to be called "Athena Noctua," and so had the little owl of Minerva as its mascot:

Sample Program Background

Essentially, the user is presented with a word and two choices (A and B). When the choice is made, the screen updates to inform the user whether the answer was correct or not before displaying the next word. Additional grammatical information can be toggled on or off with the "Extra" button. The "Setup" button allows the user to toggle on/off the NeoPixel lights and sound, which provide sensory flair when the answer is graded. They can also select the level the questions should be drawn from (lessons 1 through 40, in groups of 5). These lessons correspond to the vocabulary lists in Wheelock's Latin, 6th edition, which is the book I first took Latin from way back in 2002. I don't imagine the vocabulary lists have changed much in the newer edition of the book, though, so others might find it useful as well. The latin.json file on GitHub currently only has the first few levels; I'll update the file once I finish the complete vocabulary list.

I was able to adapt a lot of the preloaded program for my flashcard quiz. The only portion of the program that really gave me trouble was working with the json file, simply because I've only ever used csv or txt files in the past. The other thing I did was modify the default 18-pixel Arial font file that was included. The bottom of the lowercase "t" was elevated a couple of pixels above the bottoms of the other letters, and it bothered me, so I modified that section of the BDF file to extend the lowercase "t" down to the same ground level as the other letters. The code for the program can be found at GitHub.

import time
import random
import terminalio
import json
from adafruit_magtag.magtag import MagTag

We import the necessary libraries. Then we declare some global variables. I currently have the sound and lights turned off by default, so as to not annoy the rest of the household as I worked through testing.

SHOW_LIGHTS = False
PLAY_SOUNDS = False
MAX_LEVEL =  40
NUMBER_CORRECT = 0
CORRECT_ANSWER = 1
SHOW_EXTRA = True
WHICH_QUESTION = 0

We tell the MagTag to be a MagTag. Cogitat ergo est? After that, there are a number of text blocks defined. Text block 0 (counting as the code counts) is the big-font block, which shows the word being quizzed. 1-4 are the labels for the buttons (typically they say "SETUP," "A," "B," and "EXTRA" in my program). 5 is the optional "extra" grammatical details displayed under the word being quizzed. 6 is choice A for the meaning; 7 is choice B.

# main text, index 0
magtag.add_text(
    text_font ="Arial-Bold-24.bdf",
    text_position=(
        magtag.graphics.display.width // 2,
        5,
    ),
    text_scale = 1,
    line_spacing=1,
    text_anchor_point=(0.5, 0),
)
# button labels, add all 4 in one loop
for x_coord in (5, 92, 166, 220):
    magtag.add_text(
        text_font = "Arial-12.bdf",
        text_position=(x_coord, magtag.graphics.display.height - 3),
        line_spacing=1.0,
        text_anchor_point=(0, 1),
    )
# Extra
magtag.add_text(
    text_font = "Arial-12.bdf",
    text_position=(
        magtag.graphics.display.width // 2,
        38,
    ),
    text_scale = 1,
    line_spacing=1,
    text_anchor_point=(0.5, 0),
)
# Answer A
magtag.add_text(
    text_font = "Arial-18.bdf",
    text_position=(
        magtag.graphics.display.width // 2,
        56,
    ),
    text_scale = 1,
    line_spacing=1,
    text_anchor_point=(0.5, 0),
)
# Answer B
magtag.add_text(
    text_font = "Arial-18.bdf",
    text_position=(
        magtag.graphics.display.width // 2,
        83,
    ),
    text_scale = 1,
    line_spacing=1,
    text_anchor_point=(0.5, 0),
)

Now we read in the vocabulary json file:

quizQuestions = json.loads(open("latin.json").read())

The next section is a custom function that displays the next question. It randomly selects a vocabulary word from our json data, making sure that the level of the word does not exceed the current limit (either declared above or changed by the user). A wrong answer is randomly selected as well (checking to make sure that the wrong answer is not the same as the right answer, because, boy, wouldn't that be confusing!) and the program then randomly chooses whether the correct answer will appear as A or B on the quiz screen.

def displayNextQuestion():
    global MAX_LEVEL
    global CORRECT_ANSWER
    global WHICH_QUESTION
    global SHOW_EXTRA
    magtag.set_background("background_menu.bmp")
    magtag.set_text("SETUP", 1, False)
    magtag.set_text("A", 2, False)
    magtag.set_text("B", 3, False)
    magtag.set_text("EXTRA", 4, False)
    questionLevel = 41
    while questionLevel > MAX_LEVEL:
        WHICH_QUESTION = random.randint(0, len(quizQuestions)-1)
        questionLevel = quizQuestions[WHICH_QUESTION]['lesson']
    magtag.set_text(quizQuestions[WHICH_QUESTION]['question'], 0, False)
    CORRECT_ANSWER = random.randint(1,2)
    wrongAnswer = quizQuestions[WHICH_QUESTION]['answer']
    while wrongAnswer == quizQuestions[WHICH_QUESTION]['answer']:
        whichWrong = random.randint(0, len(quizQuestions)-1)
        wrongAnswer = quizQuestions[whichWrong]['answer']
    if SHOW_EXTRA == True:
        magtag.set_text(quizQuestions[WHICH_QUESTION]['parts'], 5, False)
    if SHOW_EXTRA == False:
        magtag.set_text("", 5, False)
    if CORRECT_ANSWER == 1:
        magtag.set_text('A: ' + quizQuestions[WHICH_QUESTION]['answer'], 6, False)
        magtag.set_text('B: ' + wrongAnswer, 7, False)
    if CORRECT_ANSWER == 2:
        magtag.set_text('A: ' + wrongAnswer, 6, False)
        magtag.set_text('B: ' + quizQuestions[WHICH_QUESTION]['answer'], 7, False)
    magtag.refresh()

This next bit is what first shows up when the MagTag is activated or reset. The title of the program is briefly shown.

magtag.peripherals.neopixels.brightness = 0.1
magtag.set_background("background.bmp")
magtag.set_text("STUDIUM\n LENTUM")
magtag.refresh()
if SHOW_LIGHTS == True:
    magtag.peripherals.neopixels.fill(0xFF4500)
if PLAY_SOUNDS == True:
    song = ((262, 2),(262, 2),(349,6))
    for notepair in song:
        magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
if SHOW_LIGHTS == True:
    time.sleep(0.5)
    magtag.peripherals.neopixels.fill(0xffffff)
else:
    magtag.peripherals.neopixels.fill(0x000000)

The following function defines what happens if the user chooses to go to the "Setup" screen. It gives them the option of turning the lights on/off, of turning the sound on/off, of changing the max flashcard level, or of going back to the quiz.

def changeSettings():
        # Setup Screen
        magtag.set_text('Setup', 0, False)
        magtag.set_text('   1', 1, False)
        magtag.set_text('2', 2, False)
        magtag.set_text('3', 3, False)
        magtag.set_text('BACK', 4, False)
        magtag.set_text(' 1: Light \n 2: Sound \n 3: Level', 5, False)
        magtag.set_text('', 6, False)
        magtag.set_text('', 7, True)
        while magtag.peripherals.buttons[0].value and magtag.peripherals.buttons[1].value and magtag.peripherals.buttons[2].value and magtag.peripherals.buttons[3].value:
            time.sleep(0.1)
        if not magtag.peripherals.buttons[0].value:
            changeLights()
        if not magtag.peripherals.buttons[1].value:
            changeSound()
        if not magtag.peripherals.buttons[2].value:
            changeLevel()
        if not magtag.peripherals.buttons[3].value:
            displayNextQuestion()

If they selected to change the level, they are taken to this screen, where the two center buttons allow the level to be raised or lowered (in increments of 5 - simply because waiting for the MagTag to refresh on each level from 1 to 40 would try anyone's patience).

def changeLevel():
    # Level
    global MAX_LEVEL
    newMaxLevel = MAX_LEVEL
    magtag.set_text('Max Level',0,False)
    magtag.set_text('SAVE',1,False)
    if MAX_LEVEL > 1:
        magtag.set_text('v',2,False)
    else:
        magtag.set_text('',2,False)
    if MAX_LEVEL < 41:
        magtag.set_text('^',3,False)
    else:
        magtag.set_text('',3,False)
    magtag.set_text('(1 - 40)', 5, False)
    magtag.set_text('Current: ' + str(newMaxLevel), 6, True)
    keepLooking = True
    while keepLooking:
        if not magtag.peripherals.buttons[0].value:
            MAX_LEVEL = newMaxLevel
            keepLooking = False
        if not magtag.peripherals.buttons[1].value:
            if newMaxLevel > 1:
                newMaxLevel = newMaxLevel - 5
                if newMaxLevel <= 5:
                    newMaxLevel = 5
                    magtag.set_text('',2,False)
                else:
                    magtag.set_text('v',2,False)
                if newMaxLevel >= 40:
                    newMaxLevel = 40
                    magtag.set_text('',3,False)
                else:
                    magtag.set_text('^',3,False)
                magtag.set_text('Current: ' + str(newMaxLevel), 6, True)
        if not magtag.peripherals.buttons[2].value:
            if newMaxLevel < MAX_LEVEL:
                newMaxLevel = newMaxLevel + 5
                if newMaxLevel <= 5:
                    newMaxLevel = 5
                    magtag.set_text('',2,False)
                else:
                    magtag.set_text('v',2,False)
                if newMaxLevel >= 40:
                    newMaxLevel = 40
                    magtag.set_text('',3,False)
                else:
                    magtag.set_text('^',3,False)
                magtag.set_text('Current: ' + str(newMaxLevel), 6, True)
        if not magtag.peripherals.buttons[3].value:
            changeSettings()
        time.sleep(0.1)
    displayNextQuestion()

This function defines the sound on/off toggle screen:

def changeSound():
    # Sound
    global PLAY_SOUNDS
    magtag.set_text('Sounds',0,False)
    magtag.set_text('CHANGE',1,False)
    magtag.set_text('',2,False)
    magtag.set_text('',3,False)
    if PLAY_SOUNDS == True:
        magtag.set_text('Sounds are on.',5,True)
    else:
        magtag.set_text('Sounds are off.',5,True)
    while magtag.peripherals.buttons[0].value and magtag.peripherals.buttons[3].value:
        time.sleep(0.1)
    if not magtag.peripherals.buttons[0].value:
        if PLAY_SOUNDS == True:
            PLAY_SOUNDS = False
        else:
            PLAY_SOUNDS = True
            song = ((262, 2),(262, 2),(349,6))
            for notepair in song:
                magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
        displayNextQuestion()
    if not magtag.peripherals.buttons[3].value:
        changeSettings()

This function defines the lights on/off toggle screen:

def changeLights():
    # Light
    global SHOW_LIGHTS
    magtag.set_text('Lights',0,False)
    magtag.set_text('CHANGE',1,False)
    magtag.set_text('',2,False)
    magtag.set_text('',3,False)
    if SHOW_LIGHTS == True:
        magtag.set_text('Lights are on.',5,True)
    else:
        magtag.set_text('Lights are off.',5,True)
    while magtag.peripherals.buttons[0].value and magtag.peripherals.buttons[3].value:
        time.sleep(0.1)
    if not magtag.peripherals.buttons[0].value:
        if SHOW_LIGHTS == True:
            SHOW_LIGHTS = False
            magtag.peripherals.neopixels.fill(0x000000)
        else:
            SHOW_LIGHTS = True
            magtag.peripherals.neopixels.fill(0xffffff)
        displayNextQuestion()
    if not magtag.peripherals.buttons[3].value:
        changeSettings()

And, finally, the first question is displayed and the program enters its happy infinite loop waiting for buttons to be pressed. If the first button (0) is pressed, the "Setup" screen is displayed. If the fourth button (3) is pressed, the grammatical information is turned on or off. If either of the two center buttons (1 = "A" and 2 = "B") are pressed, the answer is graded and the user is presented with either a "Correct" screen (with optional green lights and ascending tune of victory) or a "Wrong" screen (with optional red lights and descending tune of disappointment). The correct screen also displays the number of questions answered correctly in a row - don't break that winning streak! After a brief delay, the next question is then displayed and our loop begins again.

#FirstQuestion
displayNextQuestion()
while True:
    if not magtag.peripherals.buttons[0].value:
        changeSettings()
    if not magtag.peripherals.buttons[3].value:
        if SHOW_EXTRA == True:
            SHOW_EXTRA = False
            magtag.set_text('', 5, True)
        else:
            SHOW_EXTRA = True
            magtag.set_text(quizQuestions[WHICH_QUESTION]['parts'], 5, True)
    if not magtag.peripherals.buttons[2].value:
        if CORRECT_ANSWER == 2:
            NUMBER_CORRECT = NUMBER_CORRECT + 1
            magtag.set_background("owl.bmp")
            magtag.set_text('CORRECT!', 0, False)
            magtag.set_text('', 1, False)
            magtag.set_text('Current Streak: ' + str(NUMBER_CORRECT), 2, False)
            magtag.set_text('', 3, False)
            magtag.set_text('', 4, False)
            magtag.set_text('', 5, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['question'], 6, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['answer'], 7, True)
            if SHOW_LIGHTS == True:
                magtag.peripherals.neopixels.fill(0x002700)
            if PLAY_SOUNDS == True:
                song = ((262, 2),(262, 2),(349,6))
                for notepair in song:
                    magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
            if SHOW_LIGHTS == True:
                time.sleep(0.5)
                magtag.peripherals.neopixels.fill(0xffffff)
            else:
                magtag.peripherals.neopixels.fill(0x000000)
        else:
            magtag.set_background("owl.bmp")
            magtag.set_text('WRONG!', 0, False)
            magtag.set_text('', 1, False)
            magtag.set_text('', 2, False)
            magtag.set_text('', 3, False)
            magtag.set_text('', 4, False)
            magtag.set_text('', 5, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['question'], 6, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['answer'], 7, True)
            if SHOW_LIGHTS == True:
                magtag.peripherals.neopixels.fill(0x7f0000)
            if PLAY_SOUNDS == True:
                song = ((392, 2),(330, 2),(262,6))
                for notepair in song:
                    magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
            if SHOW_LIGHTS == True:
                time.sleep(0.5)
                magtag.peripherals.neopixels.fill(0xffffff)
            else:
                magtag.peripherals.neopixels.fill(0x000000)
            NUMBER_CORRECT = 0
        time.sleep(0.5)
        displayNextQuestion()
    if not magtag.peripherals.buttons[1].value:
        if CORRECT_ANSWER == 1:
            NUMBER_CORRECT = NUMBER_CORRECT + 1
            magtag.set_background("owl.bmp")
            magtag.set_text('CORRECT!', 0, False)
            magtag.set_text('', 1, False)
            magtag.set_text('Current Streak: ' + str(NUMBER_CORRECT), 2, False)
            magtag.set_text('', 3, False)
            magtag.set_text('', 4, False)
            magtag.set_text('', 5, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['question'], 6, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['answer'], 7, True)
            if SHOW_LIGHTS == True:
                magtag.peripherals.neopixels.fill(0x002700)
            if PLAY_SOUNDS == True:
                song = ((262, 2),(262, 2),(349,6))
                for notepair in song:
                    magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
            if SHOW_LIGHTS == True:
                time.sleep(0.5)
                magtag.peripherals.neopixels.fill(0xffffff)
            else:
                magtag.peripherals.neopixels.fill(0x000000)
        else:
            magtag.set_background("owl.bmp")
            magtag.set_text('WRONG!', 0, False)
            magtag.set_text('', 1, False)
            magtag.set_text('', 2, False)
            magtag.set_text('', 3, False)
            magtag.set_text('', 4, False)
            magtag.set_text('', 5, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['question'], 6, False)
            magtag.set_text(quizQuestions[WHICH_QUESTION]['answer'], 7, True)
            if SHOW_LIGHTS == True:
                magtag.peripherals.neopixels.fill(0x7f0000)
            if PLAY_SOUNDS == True:
                song = ((392, 2),(330, 2),(262,6))
                for notepair in song:
                    magtag.peripherals.play_tone(notepair[0], notepair[1] * 0.2)
            if SHOW_LIGHTS == True:
                time.sleep(0.5)
                magtag.peripherals.neopixels.fill(0xffffff)
            else:
                magtag.peripherals.neopixels.fill(0x000000)
            NUMBER_CORRECT = 0
        time.sleep(0.5)
        displayNextQuestion()
    time.sleep(0.1)

After I finished the programming (I am still working on the vocabulary json list - as of writing this, I have only made it through 8 lessons of Wheelocks!), I assembled the acrylic cloud faceplate set, hooked up the battery, and attached the magnetic feet. It's cute and functional and ready to help with mental retention!

This was a very quick project - I essentially completed it in a day - but it was fun and I really like the end result. There is a lot of debate among the language learning community around flashcards (spaced repetition vs cramming) and question format (self-grading vs multiple choice vs fill-in); I tend to think any amount of exposure is better than no exposure at all, and I know that I can't trust me to grade myself. The idea behind this project is that the MagTag would be placed on the refrigerator or in some other high-trafficked area of the house, and one would do a question or two as it was encountered throughout the day. I haven't looked at Latin in a few years, and I don't like the idea of just allowing my brain to throw out something I had devoted four college semesters toward (I read half of the Aeneid in Latin back in the day!). Maybe these brief but frequent encounters (with sensory feedback!) will help stimulate those neural connections just enough to keep them around. It was also pretty fun watching my kiddo (with zero exposure to Latin) guessing the answers to some of them and hearing her work through figuring out which answer to try.

Now I just need to figure out what I'm going to do with my long string of Neopixels.

Building Stuff - Cardboard Proud Parent Robot

I really love Simone Giertz's YouTube channel. And I particularly love her Proud Parent Machine:

I was at MicroCenter recently, and I saw that they had the "Adafruit Industries Dial Safe Pack for Circuit Playground Express" marked down to only $17. The Circuit Playground Express itself is normally $25, and this included not only the board, but also alligator clips, a battery power pack, and a servo motor - everything one would need for the Combo Dial Safe project posted on Adafruit's website. I snapped it up, but rather than make the project it was all packaged for, I used all of the pieces to make my own super-budget version of the Proud Parent Machine! I have an abundance of cardboard right now, as due to the state of the world I ended up doing the majority of my Christmas shopping online - might as well make use of it.

Most of my time on this project was spent preparing the cardboard parent. The box I used had a lot of tape on it, which meant I couldn't paint directly on it (I tried and I failed), so I essentially did what Simone did with her beautiful laser wood cutouts, only with cardboard, acrylic paint, and terrible scissors. The shirt, tie, and suit are individual pieces cut out, painted, and then glued on top of my Priority Mail base. Here is my painted and assembled robot-parent:

Robot-parent is waving.

The Circuit Playground Express is held on to the left side of the box with magnets. The alligator clips for the servo motor are attached and kind of hang down below the box, and I have the battery box duct-taped inside the Priority Mail box. I used a decent amount of duct-tape in this project.

Circuit Playground Express

Priority Mail Box Innards

The only part of the build I'm not happy with is the arm attachment. I used the small screw that came with the servo motor to attach a popsicle stick to the motor arm, but on the upward motion of the patting, the weight of the arm causes it to pivot on that screw. I really need a second small screw. Not having one of those, I applied more duct-tape around the popsicle stick, and even added a second stick to form a popsicle stick sandwich around the servo motor arm, but a small amount of pivoting is still happening. The arm has to be slightly reset after each activation.

Arm Attachment

After assembling the cardboard robot, I programmed the Circuit Playground Express. There are a number of touch-sensitive spots on the Circuit Playground Express, so I wrote the code to execute whenever circle A4 (closest to the front of the robot parent - rather easy to access) is touched. It plays a sound file where I say "Way to go, kiddo!" and then the arm lowers and raises back up twice.

Note: If you want to use the code, I recommend viewing it on GitHub.

import time
import random
import microcontroller
import pulseio
import board
from adafruit_circuitplayground.express import cpx
from adafruit_motor import servo
import touchio

# A4 is touch sensitive
touch_A4 = touchio.TouchIn(board.A4)

# create a PWMOut object on Pin A1
pwm = pulseio.PWMOut(board.A1, frequency=50)

# Create a servo object, my_servo
my_servo = servo.Servo(pwm)

while True:
    if touch_A4.value:
        time.sleep(2)
        cpx.play_file("waytogo.wav")
        for angle in range(25,85): # 0 to 49 degrees in 1 deg steps
            my_servo.angle = angle
            time.sleep(0.01)  # Tiny delay each step
        time.sleep(1)
        for angle in range(85, 25, -1):
            my_servo.angle = angle
            time.sleep(0.01)
        time.sleep(1)
        for angle in range(25,85): # 0 to 49 degrees in 1 deg steps
            my_servo.angle = angle
            time.sleep(0.01)  # Tiny delay each step
        time.sleep(1)
        for angle in range(85, 25, -1):
            my_servo.angle = angle
            time.sleep(0.01)
        time.sleep(1)

Here's video of my Cardboard Proud Parent Robot in action, patting my kid's head:

Building Stuff - RGB Matrix Wall Display

RGB Display with Thanksgiving Image

I'm not sure where I first saw the RGB Matrices that Adadfruit sells. Probably Twitter. I know I saw a few people post about their last subscription box, which contained one (and I subsequently decided to also subscribe to Adabox - I'll get my first box sometime in December). I really liked the idea of making a digital display, and, in this time of isolation, I've acquired an itch to build and create things.

I ordered the following from Adafruit, taking advantage of a 20% off coupon code they offered for securing one's account:

The display is the smallest (and thus cheapest) RGB option they have; I considered it smart to start small in case I couldn't figure things out. It features 512 LEDs.

I also used the following:

  • 10" x 10" Art Canvas - The LEDs are bright. Extremely bright. Putting the display behind an art canvas is a cheap way to diffuse the light.
  • Thin Basswood Board - Cut to pin to the back of the canvas to hold the electronics in place.
  • Tacks - Used to hold the basswood on to the back of the art canvas.
  • Cushioning foam from the RGB LED matrix panel's box - Used to help keep the electronics in place and force the LEDs up against the back of the canvas.
  • Acrylic Paint

Adafruit has a lot of useful tutorials on their website. I borrowed liberally from a number of guides while building my wall display, though I had to make modifications since I only had a fourth of the pixels available:

Finally, I used the Tom Thumb super tiny pixel font, various sprite sheets from video games, MU for programming in Circuit Python, Pixelorama for creating the BMP sprite sheets, and, finally, GIMP for making color corrections to the sprite sheets, since LEDs do not exactly see color the same way we do.

The first step was simply plugging the matrix portal into the back of the RGB Display, which embarrassingly took a couple of tries as I initially plugged it into the wrong place, and then again messed up by putting it in upside down...

Back of Electronics

Front of the RGB Display (Off)

Multi-colored lights!

Success!

Now the hard part: telling it what I wanted it to do. I wanted the display to rotate between showing the weather, the date, the time, and a series of animations. While waiting for the electronics to arrive in the mail, I worked on creating the sprite sheets for the various animations - each 32px wide and multiples of 16px tall. I created one animation each to represent the months of the year, and then I animated 11 "silly" ones to randomly appear on the display. Most of these were video game themed (Koopas, Link from the Legend of Zelda, Kirby, Ms Pacman, etc), but I also created a couple around internet memes (Nyan Cat and the "This is fine" dog). Each animation had to be arranged as a single image with the frames stacked top to bottom; to the right is an example of the Nyan Cat animation and its 23 frames.

I installed Circuit Python onto the RGB Matrix Portal, following the above Adafruit guides, and added the following libraries to the "lib" folder: adafruit_bitmap_font, adafruit_bus_device, adafruit_display_text, adafruit_esp32spi, adafruit_io, adafruit_matrixportal, adafruit_debouncer, adafruit_lis3dh, adafruit_requests, adafruit_slideshow, and neopixel. I created a "bmps" folder and put my 23 animation sprite sheets in there. I also added a "fonts" folder with the Tom Thumb font. Finally, in the base "CIRCUITPY" folder, I added the weather-icons.bmp file from the Weather Display tutorial, and three python scripts.

Computer

All programming done with canvas resting on top of LEDs to save my eyes.

The code is all available here on Github. The first python file is secrets.py - this contains user-specific information for Wifi settings, location, API keys, etc. Here is a blank template:

secrets = {
    'ssid'      : '',
    'password'  : '',
    'latitude'  : ,
    'longitude' : ,
    'timezone' : "",
    'openweather_token' : '',
    'aio_username' : '',
    'aio_key' : ''
}

I modified the openweather-graphics.py script from the "Weather Display Matrix" tutorial. I reduced the number of font sizes down to one and had it use the Tom Thumb font. I then went through and commented out every line that referenced self.medium_font.

small_font = cwd + "/fonts/tom-thumb.bdf"
# medium_font = cwd + "/fonts/Arial-14.bdf"

I altered the positioning of the temperature and icon to better suit the smaller display, and I also commented out the self.description_text, self.humidity_text, and self.wind_text sections, choosing to just stick with the temperature and weather icons.

self.temp_text = Label(self.small_font, max_glyphs=8)
        self.temp_text.x = 0
        self.temp_text.y = 3
        self.temp_text.color = TEMP_COLOR
        self._text_group.append(self.temp_text)

The Tom Thumb font does not have a degrees symbol, so I replaced that in the code with an apostrophe. I also added a couple of small definitions to have the temperature replaced by the date and the time which the main code would pass along to it - this way the date and time would appear with the chosen weather icon:

def display_time(self, the_time):
        self.temp_text.text = the_time
        self.display.show(self.root_group)
def display_date(self, the_date):
        self.temp_text.text = the_date
        self.display.show(self.root_group)

And now for the main event! The code.py file is what Circuit Python runs when power is supplied to the Matrix Portal. Here's a walk through of my code.py file:

import time
import os
import board
import displayio
import random
from digitalio import DigitalInOut, Pull, Direction
from adafruit_matrixportal.network import Network
from adafruit_matrixportal.matrix import Matrix
from adafruit_debouncer import Debouncer
from secrets import secrets
import openweather_graphics  # pylint: disable=wrong-import-position
# --- Constants ---
UNITS = "imperial"
LOCATION = "Saint Louis, US"
WEATHER_SOURCE = ("http://api.openweathermap.org/data/2.5/weather?q="
                  + LOCATION + "&units=" + UNITS)
WEATHER_SOURCE += "&appid=" + secrets["openweather_token"]
DATA_LOCATION = []
SCROLL_HOLD_TIME = 0
SPRITESHEET_FOLDER = "/bmps"
DEFAULT_FRAME_DURATION = 1  # 100ms
AUTO_ADVANCE_LOOPS = 3
TIMEZONE = secrets['timezone']
TWELVE_HOUR = True

First we import all of the libraries we need as well as declare our constants. I want the weather to pull for my current location (St. Louis) and display the temperature in Fahrenheit. I tell it where to find my animations and also define how long it should pause between frames - I have my animations running at a turtle speed of 1 frame per second.

# --- Display setup ---
matrix = Matrix(width=32, height=16, bit_depth=4)
sprite_group = displayio.Group(max_size=1)
network = Network(status_neopixel=board.NEOPIXEL, debug=False)
gfx = openweather_graphics.OpenWeather_Graphics(matrix.display,
                                                am_pm=True, units=UNITS)
matrix.display.show(sprite_group)

It is extremely important to alter the Matrix() function when using the smaller RGB matrix; most of the tutorials assume you have default 64x32 matrix, and the code will not work correctly if the proper width and height are not specified. This was a source of frustration until I figured it out - the RGB matrix was just displaying garbage until this was corrected. We essentially define two display sources here - gfx is the weather/date/time display and sprite_group is the animations. We'll flip between the two later in the code.

localtime_refresh = None
weather_refresh = None
auto_advance = True
file_list = sorted(
    [
        f
        for f in os.listdir(SPRITESHEET_FOLDER)
        if (f.endswith(".bmp") and not f.startswith("."))
    ]
)
current_image = None
current_frame = 0
current_loop = 1
frame_count = 0
frame_duration = DEFAULT_FRAME_DURATION

Here we define some more variables that will be used in the various loops. The file_list pulls in an alphabetical list of the animations in the bmps folder.

def load_image():
    """
    Load an image as a sprite
    """
    # pylint: disable=global-statement
    global current_frame, current_loop, frame_count, frame_duration
    while sprite_group:
        sprite_group.pop()

    bitmap = displayio.OnDiskBitmap(
        open(SPRITESHEET_FOLDER + "/" + file_list[current_image], "rb")
    )
    frame_count = int(bitmap.height / matrix.display.height)
    frame_duration = DEFAULT_FRAME_DURATION

    sprite = displayio.TileGrid(
        bitmap,
        pixel_shader=displayio.ColorConverter(),
        width=1,
        height=1,
        tile_width=bitmap.width,
        tile_height=matrix.display.height,
    )

    sprite_group.append(sprite)
    current_frame = 0
    current_loop = 0

I use the unaltered load_image function from the "Bitmap Pixel Art and Animation" tutorial.

def advance_image(which_image):
    """
    Advance to the next image in the list and loop back at the end
    """
    # pylint: disable=global-statement
    global current_image
    current_image = which_image
    load_image()

I did alter the advance_image function from that tutorial. In the tutorial, the code advances from one image to the next in order. I modified this to use specifically the image I passed to it, as I wanted to introduce an amount of randomness into the equation.

def advance_frame():
    """
    Advance to the next frame and loop back at the end
    """
    # pylint: disable=global-statement
    global current_frame, current_loop
    current_frame = current_frame + 1
    if current_frame >= frame_count:
        current_frame = 0
        current_loop = 1
    sprite_group[0][0] = current_frame

The advance_frame function is mostly the same as it is in the tutorial, but I am using current_loop in a different capacity - for me, it will let the code know when the current animation is done and it is time to go back to the weather/date/time, and vice-versa.

def hh_mm(time_struct):
    """ Given a time.struct_time, return a string as H:MM or HH:MM, either
        12- or 24-hour style depending on global TWELVE_HOUR setting.
        This is ONLY for 'clock time,' NOT for countdown time, which is
        handled separately in the one spot where it's needed.
    """
    if TWELVE_HOUR:
        if time_struct.tm_hour > 12:
            hour_string = str(time_struct.tm_hour - 12) # 13-23 -> 1-11 (pm)
        elif time_struct.tm_hour > 0:
            hour_string = str(time_struct.tm_hour) # 1-12
        else:
            hour_string = '12' # 0 -> 12 (am)
    else:
        hour_string = '{0:0>2}'.format(time_struct.tm_hour)
    return hour_string + ':' + '{0:0>2}'.format(time_struct.tm_min)

Here I am using the hh_mm time formating function from the "Moon Phases Clock" tutorial. It's unaltered. Now for the main loop:

while True:
    if (not localtime_refresh) or (time.monotonic() -
                                   localtime_refresh) > 3600:
        try:
            network.get_local_time()
            localtime_refresh = time.monotonic()
            value = network.fetch_data(WEATHER_SOURCE,
                                       json_path=(DATA_LOCATION,))
            weather_refresh = time.monotonic()
        except RuntimeError as e:
            print(e)
            continue

I have it set up to update the weather and sync the clock once per hour.

if (current_loop == 1):
        NOW = time.localtime()
        gfx.display_weather(value)
        time.sleep(10)
        gfx.display_time(hh_mm(NOW))
        time.sleep(10)
        gfx.display_date(str(NOW.tm_mon) + '/' + str(NOW.tm_mday))
        time.sleep(10)
        gfx.display_weather(value)
        time.sleep(10)
        gfx.display_time(hh_mm(NOW))
        time.sleep(10)
        gfx.display_date(str(NOW.tm_mon) + '/' + str(NOW.tm_mday))
        time.sleep(10)

If current_loop is 1, then we proceed through the weather/time/date section (otherwise we will cycle through the frames of an animation). On top of a backdrop with the weather icon, the code displays in turn the temperature, the time, and the date (and then repeats this once) with a ten second pause on each.

if (NOW.tm_mon == 1):  # Month Pictures
            which_pic = 4
        if (NOW.tm_mon == 2):
            which_pic = 3
        if (NOW.tm_mon == 3):
            which_pic = 10
        if (NOW.tm_mon == 4):
            which_pic = 0
        if (NOW.tm_mon == 5):
            which_pic = 11
        if (NOW.tm_mon == 6):
            which_pic = 6
        if (NOW.tm_mon == 7):
            which_pic = 5
        if (NOW.tm_mon == 8):
            which_pic = 1
        if (NOW.tm_mon == 9):
            which_pic = 21
        if (NOW.tm_mon == 10):
            which_pic = 15
        if (NOW.tm_mon == 11):
            which_pic = 13
        if (NOW.tm_mon == 12):
            which_pic = 2

Based on the date, the code selects the appropriate month animation. The animation file list is alphabetically ordered; the numbers refer to the position in that list (starting at 0). "Jan.bmp" is the 5th image in the folder, so if it is January, the code picks picture position 4.

current_loop = 0
        random_image_control = random.randint(1,22)
        if (random_image_control == 2):
            which_pic = 7  #Kirby
        if (random_image_control == 4):
            which_pic = 8  #Koopas
        if (random_image_control == 6):
            which_pic = 9  #Link
        if (random_image_control == 8):
            which_pic = 12  #Mermaid
        if (random_image_control == 10):
            which_pic = 14  #Nyan
        if (random_image_control == 12):
            which_pic = 16  #Pac1
        if (random_image_control == 14):
            which_pic = 17  #Pac2
        if (random_image_control == 16):
            which_pic = 18  #Pac3
        if (random_image_control == 18):
            which_pic = 19  #Pac4
        if (random_image_control == 20):
            which_pic = 20  #Penny
        if (random_image_control == 22):
            which_pic = 22  #ThisIsFine

We set current_loop to 0 because it is now animation time. But first, I throw some randomness into the loop. The code pics a random number between 1 and 22. If the number is odd, it keeps the selected month-specific animation. If the number is even, it replaces the picture choice with one of the 11 "silly" animations.

advance_image(which_pic)
        #advance_image(15)
        matrix.display.show(sprite_group)

The code then loads up the chosen animation and switches from the weather/time/date display to the animation display. The commented out line was for testing the appearance of the specific animations - I ended up having to color/brightness correct a number of them.

    if (current_loop == 0):
        advance_frame()
        time.sleep(frame_duration)

The code loops through the main loop for infinity. If the current_loop variable is set to 0, it skips the weather/date/time and image selection section and comes here, which simply has the display advance to the next frame of the animation and wait the defined amount of time before preceeding (1 second).

With the programming done, it was time to mount the electronics into the back of the art canvas in a secure way. I measured and cut some thin basswood to use as backing, and attached that to the canvas with thumbtacks. I adjusted the positioning and pressure on the board (so that the LEDs would press against the back of the canvas - otherwise the image would not be in focus) using bits of foam that came in the box with the display itself. Finally, I had to saw off and sand down a bit of the frame for the matrix portal to have room; otherwise it was being forced to sit at an angle that made me uncomfortable.

The completed back.

The well carved out for the matrix portal.

And that's it! I painted the front with no real plan and declared the project completed. I'll be hanging this in our living room 🙂

12/2/2020 UPDATE!

My project made it onto today's Adafruit ASK AN ENGINEER livestream!