Now we're ready to tackle some code! There are two files that power this cube, and we can briefly talk through each.

Code.py

code.py is where the main loop lives. This is where we handle the accelerometer data and the network requests to fetch new data from AdafruitIO. This file is where we initialize a Cube class that will take care of the bulk of the logic for displaying stuff on the cube. In addition, there are two main functions defined and used in this file.

  1. update_data() will make a call to AdafruitIO every time the scrolling word has finished one loop. This cadence was chosen to keep the scrolling animation relatively smooth and to reduce the amount of network calls. This function will update some global variables that are then passed into the cube.update() function
  2. orientation() will do some basic math and logic to detect the orientation of the cube in space. The resulting orientation is used to determine which Cube function to activate.
# SPDX-FileCopyrightText: 2022 Charlyn Gonda for Adafruit Industries
#
# SPDX-License-Identifier: MIT
from secrets import secrets
import ssl
import busio
import board
import adafruit_lis3dh
import wifi
import socketpool
import adafruit_requests

from adafruit_led_animation.color import (
    PURPLE, AMBER, JADE, CYAN, BLUE, GOLD, PINK)
from adafruit_io.adafruit_io import IO_HTTP

from cube import Cube

# Specify pins
top_cin = board.A0
top_din = board.A1
side_panels_cin = board.A2
side_panels_din = board.A3
bottom_cin = board.A4
bottom_din = board.A5

# Initialize cube with pins
cube = Cube(top_cin,
            top_din,
            side_panels_cin,
            side_panels_din,
            bottom_cin,
            bottom_din)

# Initial display to indicate the cube is on
cube.waiting_mode()

# Setup for Accelerometer
i2c = busio.I2C(board.SCL1, board.SDA1)
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c)

connected = False
while not connected:
    try:
        wifi.radio.connect(secrets["ssid"], secrets["password"])
        print("Connected to %s!" % secrets["ssid"])
        print("My IP address is", wifi.radio.ipv4_address)
        connected = True
    # pylint: disable=broad-except
    except Exception as error:
        print(error)
        connected = False


# Setup for http requests
pool = socketpool.SocketPool(wifi.radio)
REQUESTS = adafruit_requests.Session(pool, ssl.create_default_context())
IO = IO_HTTP(secrets["aio_username"], secrets["aio_key"], REQUESTS)

# Data for top pixels, will be updated by update_data()
TOP_PIXELS_ON = []
TOP_PIXELS_COLOR = CYAN
TOP_PIXELS_COLOR_MAP = {
    "PURPLE": PURPLE,
    "AMBER": AMBER,
    "JADE": JADE,
    "CYAN": CYAN,
    "BLUE": BLUE,
    "GOLD": GOLD,
    "PINK": PINK,
}
# Data for scrolling word, will be updated by update_data()
CUBE_WORD = "... ..."


def update_data():
    # pylint: disable=global-statement
    global CUBE_WORD, TOP_PIXELS_ON, TOP_PIXELS_COLOR
    if connected:
        print("Updating data from Adafruit IO")
        try:
            quote_feed = IO.get_feed('cube-words')
            quotes_data = IO.receive_data(quote_feed["key"])
            CUBE_WORD = quotes_data["value"]

            pixel_feed = IO.get_feed('cube-pixels')
            pixel_data = IO.receive_data(pixel_feed["key"])
            color, pixels_list = pixel_data["value"].split("-")
            TOP_PIXELS_ON = pixels_list.split(",")
            TOP_PIXELS_COLOR = TOP_PIXELS_COLOR_MAP[color]
        # pylint: disable=broad-except
        except Exception as update_error:
            print(update_error)


orientations = [
    "UP",
    "DOWN",
    "RIGHT",
    "LEFT",
    "FRONT",
    "BACK"
]

# pylint: disable=inconsistent-return-statements


def orientation(curr_x, curr_y, curr_z):
    absX = abs(curr_x)
    absY = abs(curr_y)
    absZ = abs(curr_z)

    if absX > absY and absX > absZ:
        if x >= 0:
            return orientations[1]  # up

        return orientations[0]  # down

    if absZ > absY and absZ > absX:  # when "down" is "up"
        if z >= 0:
            return orientations[2]  # left

        return orientations[3]  # right

    if absY > absX and absY > absZ:
        if y >= 0:
            return orientations[4]  # front

        return orientations[5]  # back


upside_down = False
while True:
    x, y, z = lis3dh.acceleration
    oriented = orientation(x, y, z)

    # clear cube when on one side
    # this orientation can be used while charging
    if oriented == orientations[5]:  # "back" side
        cube.clear_cube(True)
        continue

    if oriented == orientations[1]:
        upside_down = True
    else:
        upside_down = False

    if not upside_down:
        if cube.done_scrolling:
            update_data()

        cube.update(CUBE_WORD, TOP_PIXELS_COLOR, TOP_PIXELS_ON)
        cube.scroll_word_and_update_top()

    else:
        cube.upside_down_mode()

Cube.py

cube.py contains a class called Cube that is responsible for all the cube display logic and keeping track of the cube's various states. It has 5 main functions that are used inside code.py:

  1. update() is a convenience function to update both the word that scrolls through the cube and what pixels should be turned "on" for the top matrix, along what color those top pixels should be
  2. scroll_word_and_update_top() will continuously scroll through the given word and will show the pixel art on the top of the cube
  3. clear_cube() can clear the sides of the cube, with the ability to clear the top of the cube if you set clearTop=True
  4. upside_down_mode() will display a specific cube animation, and this is triggered when the cube is upside-down
  5. waiting_mode() just shows two pixels lit up on the top matrix, just to indicate that the cube is on when it first boots up.

When you assemble your cube, and you find that the word scroll orientation is not as you expected, you can try to flip the initialization of self.pixel_framebuf_sides inside the __init__ function of Cube to reverse_y=False, reverse_x=True to see if that will yield a better orientation.

Otherwise, there should be little to no modification needed for this code, but definitely feel free to modify!

# SPDX-FileCopyrightText: 2022 Charlyn Gonda for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import time
import adafruit_dotstar

from adafruit_led_animation.animation.rainbowchase import RainbowChase
from adafruit_led_animation.color import AMBER, JADE, CYAN, GOLD, PINK
from adafruit_pixel_framebuf import PixelFramebuffer


class Cube():
    def __init__(
            self,
            top_cin,
            top_din,
            side_panels_cin,
            side_panels_din,
            bottom_cin,
            bottom_din):

        # static numbers
        self.num_pixels = 64*4
        self.num_pixels_topbottom = 64
        self.pixel_width = 8*4
        self.pixel_height = 8

        # top pixels
        top_pixels = adafruit_dotstar.DotStar(
            top_cin, top_din, self.num_pixels_topbottom,
            brightness=0.03, auto_write=False)
        self.pixel_framebuf_top = PixelFramebuffer(
            top_pixels,
            self.pixel_height,
            self.pixel_height,
            rotation=1,
            alternating=False,
        )

        # side pixels
        self.side_pixels = adafruit_dotstar.DotStar(
            side_panels_cin, side_panels_din, self.num_pixels,
            brightness=0.03, auto_write=False)
        self.pixel_framebuf_sides = PixelFramebuffer(
            self.side_pixels,
            self.pixel_height,
            self.pixel_width,
            rotation=1,
            alternating=False,
            reverse_y=True,
            reverse_x=False
        )
        self.rainbow_sides = RainbowChase(
            self.side_pixels, speed=0.1, size=3, spacing=6)

        # bottom pixels
        pixels_bottom = adafruit_dotstar.DotStar(
            bottom_cin, bottom_din, self.num_pixels_topbottom,
            brightness=0.03, auto_write=False)
        self.pixel_framebuf_bottom = PixelFramebuffer(
            pixels_bottom,
            self.pixel_height,
            self.pixel_height,
            rotation=0,
            alternating=False,
            reverse_y=False,
            reverse_x=True
        )

        # scrolling word state vars
        self.last_color_time = -1
        self.color_wait = 1
        self.word = ''
        self.total_scroll_len = 0
        self.scroll_x_pos = -self.pixel_width
        self.color_idx = 0
        self.color_list = [AMBER, JADE, CYAN, PINK, GOLD]
        self.done_scrolling = False

        # whether or not the cube is already clear
        self.clear = False

        # top pixel vars
        self.top_pixel_coords = []
        self.top_pixel_color = CYAN

        # upside down mode
        self.bottom_last = -1
        self.bottom_wait = 1
        self.bottom_squares = [[0, 0, 8, 8], [
            1, 1, 6, 6], [2, 2, 4, 4], [3, 3, 2, 2]]
        self.bottom_squares_idx = 0

    def update(self, word, color, coords):
        self.word = word
        self.total_scroll_len = (len(self.word) * 5) + len(self.word)
        self.top_pixel_coords = coords
        self.top_pixel_color = color

    def scroll_word_and_update_top(self):
        if self.scroll_x_pos >= self.total_scroll_len:
            self.scroll_x_pos = -self.pixel_width
            self.clear_cube()
            self.done_scrolling = True
        else:
            self.done_scrolling = False
            self.clear = False

        self.__scroll_framebuf(self.word, self.scroll_x_pos, 0)
        self.__display_top_pixels()
        self.scroll_x_pos = self.scroll_x_pos + 1

    def clear_cube(self, clear_top=False):
        if not self.clear:
            self.pixel_framebuf_sides.fill(0)
            self.pixel_framebuf_sides.display()
            self.side_pixels.fill(0)
            self.side_pixels.show()
            self.pixel_framebuf_bottom.fill(0)
            self.pixel_framebuf_bottom.display()
            if clear_top:
                self.pixel_framebuf_top.fill(0)
                self.pixel_framebuf_top.display()
            self.clear = True

    def upside_down_mode(self):
        self.clear_cube(True)
        self.rainbow_sides.animate()
        now = time.monotonic()
        self.__bottom_square_animation(now)

    def waiting_mode(self):
        self.pixel_framebuf_top.pixel(3, 3, CYAN)
        self.pixel_framebuf_top.pixel(4, 4, PINK)
        self.pixel_framebuf_top.display()

    def __bottom_square_animation(self, now):
        self.pixel_framebuf_bottom.fill(0)
        color_int = self._rgb_to_int(CYAN)
        if now > self.bottom_last + self.bottom_wait:
            self.__coord_wrap()
            self.bottom_last = now
            x, y, w, h = self.bottom_squares[self.bottom_squares_idx]
            self.pixel_framebuf_bottom.rect(x, y, w, h, color_int)
            self.pixel_framebuf_bottom.display()

    def __coord_wrap(self):
        self.bottom_squares_idx = self.bottom_squares_idx + 1
        if self.bottom_squares_idx >= len(self.bottom_squares):
            self.bottom_squares_idx = 0

    def __display_top_pixels(self):
        self.pixel_framebuf_top.fill(0)

        for coord in self.top_pixel_coords:
            x, y = coord.split(":")
            self.pixel_framebuf_top.pixel(int(x), int(y), self.top_pixel_color)
        self.pixel_framebuf_top.display()

    def __scroll_framebuf(self, word, shift_x, shift_y):
        self.pixel_framebuf_sides.fill(0)

        color = self.__next_color()
        color_int = self._rgb_to_int(color)

        # negate x so that the word can be shown from left to right
        self.pixel_framebuf_sides.text(word, -shift_x, shift_y, color_int)
        self.pixel_framebuf_sides.display()

    def __next_color(self):
        if self.color_idx >= len(self.color_list):
            self.color_idx = 0

        result = self.color_list[self.color_idx]
        now = time.monotonic()
        if now >= self.last_color_time + self.color_wait:
            self.color_idx = self.color_idx + 1
            self.last_color_time = now

        return result

    @staticmethod
    def _rgb_to_int(rgb):
        return rgb[0] << 16 | rgb[1] << 8 | rgb[2]

Testing before final assembly

It will be a good idea to make sure that all the soldering and wiring we've done in the previous step went correctly. Upload both code.py and cube.py into your CIRCUITPY drive, and you should see this brief animation to verify that everything is working:

If you're seeing stuff on the matrices, that means you're good to go! You might even wiggle the accelerometer a bit, it should trigger the "upside down" animation which can help to make sure that the bottom matrix is also good to go.

Now we can move on to final assembly! Take a pause here and admire your work, you're almost done.

This guide was first published on Sep 07, 2022. It was last updated on Jun 25, 2024.

This page (CircuitPython Code) was last updated on Jun 25, 2024.

Text editor powered by tinymce.