Maker.io main logo

LED Matrix Alarm Clock

67

2025-08-08 | By Adafruit Industries

License: See Original Project 3D Printing Amplifiers LED Matrix STEMMA

Courtesy of Adafruit

Guide by Ruiz Brothers and Liz Clark

Overview

 

You can build a 3D printed IoT alarm clock using CircuitPython and electronics from Adafruit.

This LED matrix alarm clock is inspired by the cute plush toy monsters LaBuBu by Pop Mart.

It gets time from the internet and features sound effects, brightness dimming for nighttime and cute animations like winking eyes.

The clock is powered by an Adafruit QT Py ESP32-S3 with an amplifier for high quality audio output.

3d_printing_wink-loop

wink_2

In the CircuitPython code, you can set your time zone, alarm time, volume, and the LED brightness settings for day and night.

The clock keeps time using the internet Network Time Protocol (NTP) via WiFi. The alarm sounds are audio .mp3 files that are randomized each time they’re triggered.

Scrolling text is displayed when an alarm goes off, and a winking eyes animation plays -- giving the clock some character.

A rotary encoder is used to set the alarms. You just press on the encoder and turn the knob to set hours and minutes. Text will display to show if the alarm is On or Off.

3d_printing_alarm-loop-square

3d_printing_color-loop

Parts

parts_3

Circuit Diagram

Circuit Diagram

The diagram below provides a general visual reference for wiring of the components once you get to the Assembly page. This diagram was created using the software package Fritzing.

Adafruit Library for Fritzing

Adafruit uses the Adafruit's Fritzing parts library to create circuit diagrams for projects. You can download the library or just grab individual parts. Get the library and parts from GitHub - Adafruit Fritzing Parts.

diagram_4

Header Connections

The I2S Amplifier BFF board connects to the QT Py via short header pins and short header sockets on the QT Py.

The header pins are soldered to the I2S Amplifier BFF, under the speaker port.

The short socket headers are soldered to the QT Py, under the USB C port.

header_5

3D Printing

3D Printed Parts

STL files for 3D printing will need to be oriented for printing using either FDM or SLS machines.

Parts were printed with PLA filament.

Original design source files may be downloaded using the links below.

3MF files include multicolor sections but a multicolor printer is not required. The sections have a .1mm indented so they can easily be painted with acrylic paints or markers.

printed_6

Download 3mf files

Edit Design

Slice with settings for PLA material

The parts were sliced using BambuStudio using the slice settings below.

  • PLA filament 220c extruder

  • 0.2 layer height

  • 10% gyroid infill

  • 200mm/s print speed

  • 60c heated bed

slice_7

Install CircuitPython

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython QuickStart

Follow this step-by-step to quickly get CircuitPython running on your board.

There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM. Each version has their own UF2 build for CircuitPython. There isn't an easy way to identify which version of the board you have by looking at the board silk. If you aren't sure which version you have, try either build to see which one works.

There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM.

Download the latest version of CircuitPython for the 8MB/No PSRAM version of this board via circuitpython.org

Download the latest version of CircuitPython for the 4MB/2MB PSRAM version of this board via circuitpython.org

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

download_8

board_9

Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.

Click the reset button once (highlighted in red above), and then click it again when you see the RGB status LED(s) (highlighted in green above) turn purple (approximately half a second later). Sometimes it helps to think of it as a "slow double-click" of the reset button.

If you do not see the LED turning purple, you will need to reinstall the UF2 bootloader. See the Factory Reset page in this guide for details.

On some very old versions of the UF2 bootloader, the status LED turns red instead of purple.

For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.

Once successful, you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

If after several tries, and verifying your USB cable is data-ready, you still cannot get to the bootloader, it is possible that the bootloader is missing or damaged. Check out the Factory Reset page for details on resolving this issue.

You will see a new disk drive appear called QTPYS3BOOT.

Drag the adafruit_circuitpython_etc.uf2 file to QTPYS3BOOT.

disk_10

The BOOT drive will disappear, and a new disk drive called CIRCUITPY will appear.

That's it!

boot_11

Create Your settings.toml File

CircuitPython works with WiFi-capable boards to enable you to make projects that have network connectivity. This means working with various passwords and API keys. As of CircuitPython 8, there is support for a settings.toml file. This is a file that is stored on your CIRCUITPY drive, that contains all of your secret network information, such as your SSID, SSID password and any API keys for IoT services. It is designed to separate your sensitive information from your code.py file so you are able to share your code without sharing your credentials.

CircuitPython previously used a secrets.py file for this purpose. The settings.toml file is quite similar.

Your settings.toml file should be stored in the main directory of your CIRCUITPY drive. It should not be in a folder.

CircuitPython settings.toml File

This section will provide a couple of examples of what your settings.toml file should look like, specifically for CircuitPython WiFi projects in general.

The most minimal settings.toml file must contain your WiFi SSID and password, as that is the minimum required to connect to WiFi. Copy this example, paste it into your settings.toml, and update:

  • your_wifi_ssid

  • your_wifi_password

Download File

Copy Code
CIRCUITPY_WIFI_SSID = "your_wifi_ssid"
CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"

Many CircuitPython network-connected projects on the Adafruit Learn System involve using Adafruit IO. For these projects, you must also include your Adafruit IO username and key. Copy the following example, paste it into your settings.toml file, and update:

  • your_wifi_ssid

  • your_wifi_password

  • your_aio_username

  • your_aio_key

Download File

Copy Code
CIRCUITPY_WIFI_SSID = "your_wifi_ssid"
CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"
ADAFRUIT_AIO_USERNAME = "your_aio_username"
ADAFRUIT_AIO_KEY = "your_aio_key"

Some projects use different variable names for the entries in the settings.toml file. For example, a project might use ADAFRUIT_AIO_ID in the place of ADAFRUIT_AIO_USERNAME. If you run into connectivity issues, one of the first things to check is that the names in the settings.toml file match the names in the code.

Not every project uses the same variable name for each entry in the settings.toml file! Always verify it matches the code.

settings.toml File Tips

Here is an example settings.toml file.

Download File

Copy Code
# Comments are supported
CIRCUITPY_WIFI_SSID = "guest wifi"
CIRCUITPY_WIFI_PASSWORD = "guessable"
CIRCUITPY_WEB_API_PORT = 80
CIRCUITPY_WEB_API_PASSWORD = "passw0rd"
test_variable = "this is a test"
thumbs_up = "\U0001f44d"

In a settings.toml file, it's important to keep these factors in mind:

  • Strings are wrapped in double quotes; ex: "your-string-here"

  • Integers are not quoted and may be written in decimal with optional sign (+1, -1, 1000) or hexadecimal (0xabcd).

    • Floats, octal (0o567) and binary (0b11011) are not supported.

  • Use \u escapes for weird characters, \x and \ooo escapes are not available in .toml files

    • Example: \U0001f44d for 👍 (thumbs up emoji) and \u20ac for € (EUR sign)

  • Unicode emoji, and non-ASCII characters, stand for themselves as long as you're careful to save in "UTF-8 without BOM" format

When your settings.toml file is ready, you can save it in your text editor with the .toml extension.

settings_11

Accessing Your settings.toml Information in code.py

In your code.py file, you'll need to import the os library to access the settings.toml file. Your settings are accessed with the os.getenv() function. You'll pass your settings entry to the function to import it into the code.py file.

Download File

Copy Code
import os

print(os.getenv("test_variable"))

output_a

In the upcoming CircuitPython WiFi examples, you'll see how the settings.toml file is used for connecting to your SSID and accessing your API keys.

Code the Clock

Once you've finished setting up your QT Py ESP32-S3 with CircuitPython, you can access the code, audio files and necessary libraries by downloading the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.

code_12

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
# SPDX-License-Identifier: MIT

'''LED Matrix Alarm Clock with Scrolling Wake Up Text and Winking Eyes'''
import os
import ssl
import time
import random
import wifi
import socketpool
import microcontroller
import board
import audiocore
import audiobusio
import audiomixer
import adafruit_is31fl3741
from adafruit_is31fl3741.adafruit_rgbmatrixqt import Adafruit_RGBMatrixQT
import adafruit_ntp
from adafruit_ticks import ticks_ms, ticks_add, ticks_diff
from rainbowio import colorwheel
from adafruit_seesaw import digitalio, rotaryio, seesaw
from adafruit_debouncer import Button

# Configuration
timezone = -4
alarm_hour = 11
alarm_min = 36
alarm_volume = .2
hour_12 = True
no_alarm_plz = False
BRIGHTNESS_DAY = 200
BRIGHTNESS_NIGHT = 50

# I2S pins for Audio BFF
DATA = board.A0
LRCLK = board.A1
BCLK = board.A2

# Connect to WIFI
wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
print(f"Connected to {os.getenv('CIRCUITPY_WIFI_SSID')}")

context = ssl.create_default_context()
pool = socketpool.SocketPool(wifi.radio)
ntp = adafruit_ntp.NTP(pool, tz_offset=timezone, cache_seconds=3600)

# Initialize I2C and displays
i2c = board.STEMMA_I2C()
matrix1 = Adafruit_RGBMatrixQT(i2c, address=0x30, allocate=adafruit_is31fl3741.PREFER_BUFFER)
matrix2 = Adafruit_RGBMatrixQT(i2c, address=0x31, allocate=adafruit_is31fl3741.PREFER_BUFFER)

# Configure displays
for m in [matrix1, matrix2]:
    m.global_current = 0x05
    m.set_led_scaling(BRIGHTNESS_DAY)
    m.enable = True
    m.fill(0x000000)
    m.show()

# Audio setup
audio = audiobusio.I2SOut(BCLK, LRCLK, DATA)
wavs = ["/"+f for f in os.listdir('/') if f.lower().endswith('.wav') and not f.startswith('.')]
mixer = audiomixer.Mixer(voice_count=1, sample_rate=22050, channel_count=1,
                         bits_per_sample=16, samples_signed=True, buffer_size=32768)
mixer.voice[0].level = alarm_volume
audio.play(mixer)

def open_audio():
    """Open a random WAV file"""
    filename = random.choice(wavs)
    return audiocore.WaveFile(open(filename, "rb"))

def update_brightness(hour_24):
    """Update LED brightness based on time of day"""
    brightness = BRIGHTNESS_NIGHT if (hour_24 >= 20 or hour_24 < 7) else BRIGHTNESS_DAY
    matrix1.set_led_scaling(brightness)
    matrix2.set_led_scaling(brightness)
    return brightness

# Seesaw setup for encoder and button
seesaw = seesaw.Seesaw(i2c, addr=0x36)
seesaw.pin_mode(24, seesaw.INPUT_PULLUP)
button = Button(digitalio.DigitalIO(seesaw, 24), long_duration_ms=1000)
encoder = rotaryio.IncrementalEncoder(seesaw)
last_position = 0

# Font definitions
FONT_5X7 = {
    '0': [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
    '1': [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
    '2': [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
    '3': [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
    '4': [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
    '5': [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
    '6': [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
    '7': [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
    '8': [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
    '9': [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
    ' ': [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
    'W': [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
    'A': [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
    'K': [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
    'E': [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
    'U': [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
    'P': [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
    'O': [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
    'N': [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
    'F': [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000]
}

# Eye patterns
EYE_OPEN = [0b10101, 0b01110, 0b10001, 0b10101, 0b10001, 0b01110, 0b00000]
EYE_CLOSED = [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000]

class Display:
    """Handle all display operations"""
    def __init__(self, m1, m2):
        self.matrix1 = m1
        self.matrix2 = m2

    def clear(self):
        """Clear both displays"""
        self.matrix1.fill(0x000000)
        self.matrix2.fill(0x000000)

    def show(self):
        """Update both displays"""
        self.matrix1.show()
        self.matrix2.show()

    def pixel(self, matrix, x, y, color): # pylint: disable=no-self-use
        """Draw a pixel with 180-degree rotation"""
        fx, fy = 12 - x, 8 - y
        if 0 <= fx < 13 and 0 <= fy < 9:
            matrix.pixel(fx, fy, color)

    def draw_char(self, matrix, char, x, y, color):
        """Draw a character at position x,y"""
        if char.upper() in FONT_5X7:
            bitmap = FONT_5X7[char.upper()]
            for row in range(7):
                for col in range(5):
                    if bitmap[row] & (1 << (4 - col)):
                        self.pixel(matrix, x + col, y + row, color)

    def draw_colon(self, y, color, is_pm=False):
        """Draw colon split between displays with optional PM indicator"""
        # Two dots for the colon
        for dy in [(1, 2), (4, 5)]:
            for offset in dy:
                self.pixel(self.matrix1, 12, y + offset, color)
                self.pixel(self.matrix2, 0, y + offset, color)
        # PM indicator dot
        if is_pm:
            self.pixel(self.matrix1, 12, y + 6, color)
            self.pixel(self.matrix2, 0, y + 6, color)

    def draw_time(self, time_str, color, is_pm=False):
        """Draw time display across both matrices"""
        self.clear()
        y = 1
        # Draw digits
        if len(time_str) >= 5:
            self.draw_char(self.matrix1, time_str[0], 0, y, color)
            self.draw_char(self.matrix1, time_str[1], 6, y, color)
            self.draw_colon(y, color, is_pm)
            self.draw_char(self.matrix2, time_str[3], 2, y, color)
            self.draw_char(self.matrix2, time_str[4], 8, y, color)
        self.show()

    def draw_scrolling_text(self, text, offset, color):
        """Draw scrolling text across both matrices"""
        self.clear()
        char_width = 6
        total_width = 26
        # Calculate position for smooth scrolling
        y = 1
        for i, char in enumerate(text):
            # Start from right edge and move left
            char_x = total_width - offset + (i * char_width)
            # Draw character if any part is visible
            if -6 < char_x < total_width:
                if char_x < 13:  # On matrix1
                    self.draw_char(self.matrix1, char, char_x, y, color)
                else:  # On matrix2
                    self.draw_char(self.matrix2, char, char_x - 13, y, color)
        self.show()

    def draw_eye(self, matrix, pattern, color):
        """Draw eye pattern centered on matrix"""
        x, y = 4, 1  # Center position
        for row in range(7):
            for col in range(5):
                if pattern[row] & (1 << (4 - col)):
                    self.pixel(matrix, x + col, y + row, color)

    def wink_animation(self, color):
        """Perform winking animation"""
        # Sequence: open -> left wink -> open -> right wink -> open
        sequences = [
            (EYE_OPEN, EYE_OPEN),
            (EYE_CLOSED, EYE_OPEN),
            (EYE_OPEN, EYE_OPEN),
            (EYE_OPEN, EYE_CLOSED),
            (EYE_OPEN, EYE_OPEN)
        ]
        for left_eye, right_eye in sequences:
            self.clear()
            self.draw_eye(self.matrix1, left_eye, color)
            self.draw_eye(self.matrix2, right_eye, color)
            self.show()
            time.sleep(0.3)

    def blink_time(self, time_str, color, is_pm=False, count=3):
        """Blink time display for mode changes"""
        for _ in range(count):
            self.clear()
            self.show()
            time.sleep(0.2)
            self.draw_time(time_str, color, is_pm)
            time.sleep(0.2)

# Initialize display handler
display = Display(matrix1, matrix2)

# State variables
class State:
    """Track all state variables"""
    def __init__(self):
        self.color_value = 0
        self.color = colorwheel(0)
        self.is_pm = False
        self.alarm_is_pm = False
        self.time_str = "00:00"
        self.set_alarm = 0
        self.active_alarm = False
        self.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
        self.current_brightness = BRIGHTNESS_DAY
        # Timers
        self.refresh_timer = Timer(3600000)  # 1 hour
        self.clock_timer = Timer(1000)       # 1 second
        self.wink_timer = Timer(30000)       # 30 seconds
        self.scroll_timer = Timer(80)        # Scroll speed
        self.blink_timer = Timer(500)        # Blink speed
        self.alarm_status_timer = Timer(100) # Status scroll
        # Display state
        self.scroll_offset = 0
        self.blink_state = True
        self.showing_status = False
        self.status_start_time = 0
        self.alarm_start_time = 0
        # Time tracking
        self.first_run = True
        self.seconds = 0
        self.mins = 0
        self.am_pm_hour = 0

class Timer:
    """Simple timer helper"""
    def __init__(self, interval):
        self.interval = interval
        self.last_tick = ticks_ms()

    def check(self):
        """Check if timer has elapsed"""
        if ticks_diff(ticks_ms(), self.last_tick) >= self.interval:
            self.last_tick = ticks_add(self.last_tick, self.interval)
            return True
        return False

    def reset(self):
        """Reset timer"""
        self.last_tick = ticks_ms()

# Initialize state
state = State()

def format_time_display(hour_24, minute, use_12hr=True):
    """Format time for display with AM/PM detection"""
    if use_12hr:
        hour = hour_24 % 12
        if hour == 0:
            hour = 12
        is_pm = hour_24 >= 12
    else:
        hour = hour_24
        is_pm = False
    return f"{hour:02}:{minute:02}", is_pm

def sync_time():
    """Sync with NTP server"""
    try:
        print("Getting time from internet!")
        now = ntp.datetime
        state.am_pm_hour = now.tm_hour
        state.mins = now.tm_min
        state.seconds = now.tm_sec
        state.time_str, state.is_pm = format_time_display(state.am_pm_hour, state.mins, hour_12)
        update_brightness(state.am_pm_hour)
        if not state.active_alarm and not state.showing_status:
            display.draw_time(state.time_str, state.color, state.is_pm)
        print(f"Time: {state.time_str}")
        state.first_run = False
        return True
    except Exception as e: # pylint: disable=broad-except
        print(f"Error syncing time: {e}")
        return False

# Main loop
while True:
    button.update()

    # Handle button presses
    if button.long_press:
        if state.set_alarm == 0 and not state.active_alarm:
            # Enter alarm setting mode
            state.blink_timer.reset()
            state.set_alarm = 1
            state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.blink_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
            # Draw the alarm hour after blinking to keep it displayed
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
        elif state.active_alarm:
            # Stop alarm
            mixer.voice[0].stop()
            state.active_alarm = False
            update_brightness(state.am_pm_hour)
            state.scroll_offset = 0
            # Immediately redraw the current time
            display.draw_time(state.time_str, state.color, state.is_pm)
            print("Alarm silenced")

    if button.short_count == 1:  # Changed from == 1 to >= 1 for better detection
        # Cycle through alarm setting modes
        state.set_alarm = (state.set_alarm + 1) % 3
        if state.set_alarm == 0:
            # Exiting alarm setting mode - redraw current time
            state.wink_timer.reset()
            display.draw_time(state.time_str, state.color, state.is_pm)
        elif state.set_alarm == 1:
            # Entering hour setting
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
              # Reset timer to prevent immediate blinking
        elif state.set_alarm == 2:
            # Entering minute setting
            display.blink_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
            # Draw the minutes after blinking to keep them displayed
            display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
              # Reset timer to prevent immediate blinking

    if button.short_count == 3:  # Changed for better detection
        # Toggle alarm on/off
        no_alarm_plz = not no_alarm_plz
        print(f"Alarm disabled: {no_alarm_plz}")
        state.showing_status = True
        state.status_start_time = ticks_ms()
        state.scroll_offset = 0

    # Handle encoder (your existing code)
    position = -encoder.position
    if position != last_position:
        delta = 1 if position > last_position else -1
        if state.set_alarm == 0:
            # Change color
            state.color_value = (state.color_value + delta * 5) % 255
            state.color = colorwheel(state.color_value)
            display.draw_time(state.time_str, state.color, state.is_pm)
        elif state.set_alarm == 1:
            # Change hour
            alarm_hour = (alarm_hour + delta) % 24
            state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
        elif state.set_alarm == 2:
            # Change minute
            alarm_min = (alarm_min + delta) % 60
            display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
        state.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
        last_position = position

    # Handle alarm status display
    if state.showing_status:
        if state.alarm_status_timer.check():
            status_text = "OFF " if no_alarm_plz else "ON "
            display.draw_scrolling_text(status_text, state.scroll_offset, state.color)
            text_width = 4*6 if no_alarm_plz else 3*6
            state.scroll_offset += 1
            # Reset when text has completely scrolled off
            if state.scroll_offset > text_width + 18:
                state.scroll_offset = 0
                state.showing_status = False
                if state.set_alarm == 0 and not state.active_alarm:
                    display.draw_time(state.time_str, state.color, state.is_pm)

    # Handle active alarm scrolling
    if state.active_alarm:
        # Auto-silence alarm after 1 minute
        if ticks_diff(ticks_ms(), state.alarm_start_time) >= 60000:
            mixer.voice[0].stop()
            state.active_alarm = False
            update_brightness(state.am_pm_hour)
            state.scroll_offset = 0
            display.draw_time(state.time_str, state.color, state.is_pm)
            print("Alarm auto-silenced")
        elif state.scroll_timer.check():
            display.draw_scrolling_text("WAKE UP ", state.scroll_offset, state.color)
            text_width = 8 * 6  # "WAKE UP " is 8 characters
            state.scroll_offset += 1
            # Reset when text has completely scrolled off
            if state.scroll_offset > text_width + 26:
                state.scroll_offset = 0

    # Handle alarm setting mode blinking
    elif state.set_alarm > 0:
        # Only blink if enough time has passed since mode change
        if state.blink_timer.check():
            state.blink_state = not state.blink_state
            if state.blink_state:
                # Redraw during the "on" part of blink
                if state.set_alarm == 1:
                    hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
                    display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
                else:
                    display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
            else:
                # Only clear display during the "off" part of blink
                display.clear()
                display.show()

    # Normal mode operations
    else:  # state.set_alarm == 0
        # Winking animation
        if not state.active_alarm and not state.showing_status and state.wink_timer.check():
            print("Winking!")
            display.wink_animation(state.color)
            display.draw_time(state.time_str, state.color, state.is_pm)

        # Time sync
        if state.refresh_timer.check() or state.first_run:
            if not sync_time():
                time.sleep(10)
                microcontroller.reset()

        # Local timekeeping
        if state.clock_timer.check():
            state.seconds += 1
            if state.seconds > 59:
                state.seconds = 0
                state.mins += 1
                if state.mins > 59:
                    state.mins = 0
                    state.am_pm_hour = (state.am_pm_hour + 1) % 24
                    update_brightness(state.am_pm_hour)
                # Update display
                state.time_str, state.is_pm = format_time_display(state.am_pm_hour,
                                                                  state.mins, hour_12)
                if not state.active_alarm and not state.showing_status:
                    display.draw_time(state.time_str, state.color, state.is_pm)
                # Check alarm
                if f"{state.am_pm_hour:02}:{state.mins:02}" == state.alarm_str and not no_alarm_plz:
                    print("ALARM!")
                    wave = open_audio()
                    mixer.voice[0].play(wave, loop=True)
                    state.active_alarm = True
                    state.alarm_start_time = ticks_ms()
                    state.scroll_offset = 0

View on GitHub

Upload the Code and Libraries to the QT Py ESP32-S3

After downloading the Project Bundle, plug your QT Py ESP32-S3 into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the QT Py ESP32-S3's CIRCUITPY drive.

  • lib folder

  • nice-alarm.wav

  • square-alarm.wav

  • code.py

Your QT Py ESP32-S3 CIRCUITPY drive should look like this after copying the lib folder, two .WAV files and code.py file:

drive_13

Add Your settings.toml File

As of CircuitPython 8.0.0, there is support for Environment Variables. Environment variables are stored in a settings.toml file. Similar to secrets.py, the settings.toml file separates your sensitive information from your main code.py file. Add your settings.toml file as described in the Create Your settings.toml File page earlier in this guide. You'll need to include values for your CIRCUITPY_WIFI_SSID and CIRCUITPY_WIFI_PASSWORD.

Download File

Copy Code
CIRCUITPY_WIFI_SSID = "your-ssid-here"
CIRCUITPY_WIFI_PASSWORD = "your-ssid-password-here"

How the CircuitPython Code Works

At the top of the code are user configurable settings for the clock. You'll set your time zone, alarm time, alarm volume, 12 hours vs. 24 hour time and LED brightness for day and night.

Download File

Copy Code
# Configuration
timezone = -4
alarm_hour = 11
alarm_min = 36
alarm_volume = .2
hour_12 = True
no_alarm_plz = False
BRIGHTNESS_DAY = 200
BRIGHTNESS_NIGHT = 50

WiFi

The clock keeps time using network time protocol (NTP) with WiFi.

Download File

Copy Code
# Connect to WIFI
wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
print(f"Connected to {os.getenv('CIRCUITPY_WIFI_SSID')}")

context = ssl.create_default_context()
pool = socketpool.SocketPool(wifi.radio)
ntp = adafruit_ntp.NTP(pool, tz_offset=timezone, cache_seconds=3600)

LEDs

The two RGB matrix displays connect over I2C. One is on the default address (0x30) and the second is on address 0x31.

Download File

Copy Code
# Initialize I2C and displays
i2c = board.STEMMA_I2C()
matrix1 = Adafruit_RGBMatrixQT(i2c, address=0x30, allocate=adafruit_is31fl3741.PREFER_BUFFER)
matrix2 = Adafruit_RGBMatrixQT(i2c, address=0x31, allocate=adafruit_is31fl3741.PREFER_BUFFER)

# Configure displays
for m in [matrix1, matrix2]:
    m.global_current = 0x05
    m.set_led_scaling(BRIGHTNESS_DAY)
    m.enable = True
    m.fill(0x000000)
    m.show()

Audio

.WAV files are played when the alarm goes off on the clock. Any .WAV file that is added to the CIRCUITPY drive is stored in the wavs array. When an alarm is triggered, one of the .WAV files is opened and played on a loop.

Download File

Copy Code
# Audio setup
audio = audiobusio.I2SOut(BCLK, LRCLK, DATA)
wavs = ["/"+f for f in os.listdir('/') if f.lower().endswith('.wav') and not f.startswith('.')]
mixer = audiomixer.Mixer(voice_count=1, sample_rate=22050, channel_count=1,
                         bits_per_sample=16, samples_signed=True, buffer_size=32768)
mixer.voice[0].level = alarm_volume
audio.play(mixer)

def open_audio():
    """Open a random WAV file"""
    filename = random.choice(wavs)
    return audiocore.WaveFile(open(filename, "rb"))

seesaw

The rotary encoder is instantiated over I2C. The button on the encoder is passed to a debouncer Button object. This lets you use long press and short press detection.

Download File

Copy Code
# Seesaw setup for encoder and button
seesaw = seesaw.Seesaw(i2c, addr=0x36)
seesaw.pin_mode(24, seesaw.INPUT_PULLUP)
button = Button(digitalio.DigitalIO(seesaw, 24), long_duration_ms=1000)
encoder = rotaryio.IncrementalEncoder(seesaw)
last_position = 0

Custom Display Class

A custom 5x7 font, eye bitmaps and Display class is used for the LED matrices. The custom class takes care of drawing characters, animating the eyes, scrolling text and rotating the displays in software.

Download File

Copy Code
# Eye patterns
EYE_OPEN = [0b10101, 0b01110, 0b10001, 0b10101, 0b10001, 0b01110, 0b00000]
EYE_CLOSED = [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000]

class Display:
    """Handle all display operations"""
    def __init__(self, m1, m2):
        self.matrix1 = m1
        self.matrix2 = m2

State Tracking

A State class takes care of tracking all of the different states, modes and timers used in the loop.

Download File

Copy Code
# State variables
class State:
    """Track all state variables"""
    def __init__(self):
        self.color_value = 0
        self.color = colorwheel(0)
        self.is_pm = False
        self.alarm_is_pm = False
        self.time_str = "00:00"
        self.set_alarm = 0
        self.active_alarm = False
        self.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
        self.current_brightness = BRIGHTNESS_DAY
        # Timers
        self.refresh_timer = Timer(3600000)  # 1 hour
        self.clock_timer = Timer(1000)       # 1 second
        self.wink_timer = Timer(30000)       # 30 seconds
        self.scroll_timer = Timer(80)        # Scroll speed
        self.blink_timer = Timer(500)        # Blink speed
        self.alarm_status_timer = Timer(100) # Status scroll
        # Display state
        self.scroll_offset = 0
        self.blink_state = True
        self.showing_status = False
        self.status_start_time = 0
        self.alarm_start_time = 0
        # Time tracking
        self.first_run = True
        self.seconds = 0
        self.mins = 0
        self.am_pm_hour = 0

The Loop

In the loop, the button is tracked to determine if a long press or short press is received. A long press lets you set a new alarm on the clock or turn off an active alarm. A single short press lets you navigate the alarm setting. Three short presses in a row lets you toggle the alarm on or off.

Download File

Copy Code
# Main loop
while True:
    button.update()

    # Handle button presses
    if button.long_press:
        if state.set_alarm == 0 and not state.active_alarm:
            # Enter alarm setting mode
            state.blink_timer.reset()
            state.set_alarm = 1
            state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.blink_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
            # Draw the alarm hour after blinking to keep it displayed
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
        elif state.active_alarm:
            # Stop alarm
            mixer.voice[0].stop()
            state.active_alarm = False
            update_brightness(state.am_pm_hour)
            state.scroll_offset = 0
            # Immediately redraw the current time
            display.draw_time(state.time_str, state.color, state.is_pm)
            print("Alarm silenced")

    if button.short_count == 1:
        # Cycle through alarm setting modes
        state.set_alarm = (state.set_alarm + 1) % 3
        if state.set_alarm == 0:
            # Exiting alarm setting mode - redraw current time
            state.wink_timer.reset()
            display.draw_time(state.time_str, state.color, state.is_pm)
        elif state.set_alarm == 1:
            # Entering hour setting
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
              # Reset timer to prevent immediate blinking
        elif state.set_alarm == 2:
            # Entering minute setting
            display.blink_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
            # Draw the minutes after blinking to keep them displayed
            display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
              # Reset timer to prevent immediate blinking

    if button.short_count == 3:
        # Toggle alarm on/off
        no_alarm_plz = not no_alarm_plz
        print(f"Alarm disabled: {no_alarm_plz}")
        state.showing_status = True
        state.status_start_time = ticks_ms()
        state.scroll_offset = 0

Encoder

The encoder lets you change the color of the RGB LEDs. It cycles through the rainbow. When you are setting a new alarm, the encoder lets you rotate through the hours and minutes.

Download File

Copy Code
# Handle encoder (your existing code)
    position = -encoder.position
    if position != last_position:
        delta = 1 if position > last_position else -1
        if state.set_alarm == 0:
            # Change color
            state.color_value = (state.color_value + delta * 5) % 255
            state.color = colorwheel(state.color_value)
            display.draw_time(state.time_str, state.color, state.is_pm)
        elif state.set_alarm == 1:
            # Change hour
            alarm_hour = (alarm_hour + delta) % 24
            state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
            hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
            display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
        elif state.set_alarm == 2:
            # Change minute
            alarm_min = (alarm_min + delta) % 60
            display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
        state.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
        last_position = position

Text on a Clock

When you toggle the alarm with three short button presses, "ON" or "OFF" scrolls across the displays to let you know if the alarm is on or off.

Download File

Copy Code
# Handle alarm status display
    if state.showing_status:
        if state.alarm_status_timer.check():
            status_text = "OFF " if no_alarm_plz else "ON "
            display.draw_scrolling_text(status_text, state.scroll_offset, state.color)
            text_width = 4*6 if no_alarm_plz else 3*6
            state.scroll_offset += 1
            # Reset when text has completely scrolled off
            if state.scroll_offset > text_width + 18:
                state.scroll_offset = 0
                state.showing_status = False
                if state.set_alarm == 0 and not state.active_alarm:
                    display.draw_time(state.time_str, state.color, state.is_pm)

When an alarm is active, "WAKE UP" scrolls across the displays.

Download File

Copy Code
# Handle active alarm scrolling
    if state.active_alarm:
        # Auto-silence alarm after 1 minute
        if ticks_diff(ticks_ms(), state.alarm_start_time) >= 60000:
            mixer.voice[0].stop()
            state.active_alarm = False
            update_brightness(state.am_pm_hour)
            state.scroll_offset = 0
            display.draw_time(state.time_str, state.color, state.is_pm)
            print("Alarm auto-silenced")
        elif state.scroll_timer.check():
            display.draw_scrolling_text("WAKE UP ", state.scroll_offset, state.color)
            text_width = 8 * 6  # "WAKE UP " is 8 characters
            state.scroll_offset += 1
            # Reset when text has completely scrolled off
            if state.scroll_offset > text_width + 26:
                state.scroll_offset = 0

Setting an Alarm

When you are setting a new alarm, the hours or minutes will blink on and off.

Download File

Copy Code
# Handle alarm setting mode blinking
    elif state.set_alarm > 0:
        # Only blink if enough time has passed since mode change
        if state.blink_timer.check():
            state.blink_state = not state.blink_state
            if state.blink_state:
                # Redraw during the "on" part of blink
                if state.set_alarm == 1:
                    hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
                    display.draw_time(hour_str[:2] + ":  ", state.color, state.alarm_is_pm)
                else:
                    display.draw_time(f"  :{alarm_min:02}", state.color, state.alarm_is_pm)
            else:
                # Only clear display during the "off" part of blink
                display.clear()
                display.show()

Eyes on a Clock

When the clock is just being a clock, you'll see the blinking eye animation every 30 seconds.

Download File

Copy Code
else:  # state.set_alarm == 0
        # Winking animation
        if not state.active_alarm and not state.showing_status and state.wink_timer.check():
            print("Winking!")
            display.wink_animation(state.color)
            display.draw_time(state.time_str, state.color, state.is_pm)

Finally, the Clock Code

Every hour, the clock syncs with the NTP server to make sure that the time is accurate. Between syncs, time is kept locally on the QT Py using ticks().

Download File

Copy Code
# Time sync
        if state.refresh_timer.check() or state.first_run:
            if not sync_time():
                time.sleep(10)
                microcontroller.reset()

        # Local timekeeping
        if state.clock_timer.check():
            state.seconds += 1
            if state.seconds > 59:
                state.seconds = 0
                state.mins += 1
                if state.mins > 59:
                    state.mins = 0
                    state.am_pm_hour = (state.am_pm_hour + 1) % 24
                    update_brightness(state.am_pm_hour)
                # Update display
                state.time_str, state.is_pm = format_time_display(state.am_pm_hour,
                                                                  state.mins, hour_12)
                if not state.active_alarm and not state.showing_status:
                    display.draw_time(state.time_str, state.color, state.is_pm)
                # Check alarm
                if f"{state.am_pm_hour:02}:{state.mins:02}" == state.alarm_str and not no_alarm_plz:
                    print("ALARM!")
                    wave = open_audio()
                    mixer.voice[0].play(wave, loop=True)
                    state.active_alarm = True
                    state.alarm_start_time = ticks_ms()
                    state.scroll_offset = 0

Assemble

Header Connections

The I2S Amplifier BFF board connects to the QT Py via short header pins and short header sockets on the QT Py.

The header pins are soldered to the I2S Amplifier BFF, under the speaker port.

The short socket headers are soldered to the QT Py, under the USB C port.

assemble_14

assemble_15

Speaker Wires

Solder Pico Plug wires to the speaker.

+ connects to the red wire

- connects to the black wire

Cut Matrix Pad Jumper to the second display

Use a hobby knife or flush cutters to carefully cut the trace on the 0x30 pad jumper.

Add solder to join the 0x31 pad as seen here.

cut_16

Connect Matrix display

Use 50mm STEMMA cable to connect the two matrix displays.

Use a 100mm STEMMA cable on the left display (connects to the QTPy).

Use a 200mm or longer, STEMMA cable on the right display (connects to the rotary encoder board).

connect_17

Mount grid cover

Place the grid part into the inside the "front dome" part. The grid press fits into the display cut out.

Mount Matrix displays

Use M2.5x5mm screws to mount the matrix displays to the standoffs inside the front dome part.

mount_18

mount_19

Connect cables to QTPy and Audio BFF

Attach the STEMMA cables to the QT Py and the speaker wire to the Audio BFF boards.

Board Mount

Align the QT Py board to the mount with the USB port facing the two mounting tabs.

cables_20

cables_21

Press fit the QT Py to mount

The QT Py mounts to the holder part with the Audio BFF facing down.

Add M3x5mm screws to the two mounting tabs on the holder.

press_22

press_23

Rotary knob

Remove the hex nut and washer to fit the rotary knob into the back dome part.

Plug the STEMMA cable to the port and mount the rotary board to the back dome, with the port to one side as shown.

Use M2.5 screws to mount the rotary board to the standoffs inside the dome part.

rotary_24

rotary_25

Mount speaker

Place the speaker into the back dome cut out part with the wire contacts facing out.

Once the speaker is placed, rotate until the contacts face towards the back of the dome part.

speaker_26

speaker_27

Snap fit domes

Align the speaker cutout on the front dome to the back dome and firmly press fit together to attach both halves of the dome parts.

snap_28

Attach rotary knob tail

Align the D shape on the steam to the cutout on the printed tail knob.

Press fit the tail part to the stem to fit.

attach_29

attach_30

Complete

Connect a USB C cable into the port cutout to power the clock!

3d_printing_color-loop

Mfr Part # 5201
STEMMA QT IS31FL3741 MATRIX DRVR
Adafruit Industries LLC
$123.07
View More Details
Mfr Part # 5700
ADAFRUIT QT PY S3 WITH 2MB PSRAM
Adafruit Industries LLC
$102.90
View More Details
Mfr Part # 5426
ADAFRUIT QT PY ESP32-S3 WIFI DEV
Adafruit Industries LLC
$102.90
View More Details
Mfr Part # 5770
ADAFRUIT I2S AMPLIFIER BFF ADD-O
Adafruit Industries LLC
Mfr Part # 3968
SPEAKER 4OHM 3W TOP PORT
Adafruit Industries LLC
Mfr Part # 3922
MOLEX PICO BLADE 2-PIN CABLE - 2
Adafruit Industries LLC
Mfr Part # 5880
ADAFRUIT I2C STEMMA QT ROTARY EN
Adafruit Industries LLC
Mfr Part # 4399
STEMMA QWIIC JST SH CABLE 50MM
Adafruit Industries LLC
Mfr Part # 4210
JST SH 4-PIN CABLE - QWIIC COMPA
Adafruit Industries LLC
Mfr Part # 5384
STEMMA QT/QWIIC CABLE 300MM
Adafruit Industries LLC
Mfr Part # 5044
CABLE A PLUG TO C PLUG 6.5'
Adafruit Industries LLC
LITTLE RUBBER BUMPER FEET - PACK
Mfr Part # 550
LITTLE RUBBER BUMPER FEET - PACK
Adafruit Industries LLC
36-PIN 0.1 SHORT FEMALE HEADER -
Mfr Part # 3008
36-PIN 0.1 SHORT FEMALE HEADER -
Adafruit Industries LLC
MALE HEADER 36-PIN 0.1 SHORT BRE
Mfr Part # 3009
MALE HEADER 36-PIN 0.1 SHORT BRE
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.