Not sure what time it is? Never quite got the hang of reading an analog clock? Ask Adabot! In this project, you'll use an RP2040 Prop-Maker Feather running CircuitPython with a DS3231 real-time clock breakout to build a friendly, talking clock. Whenever you press the button on the top of Adabot's head, he will announce the current time for you.

Adabot's head is a remix of...Adabot's head from the Adabot Toy Robot Friend project. To accommodate the clock electronics, Adabot's head was lengthened and a few mounting holes were added.

Adabot Clock: The Remix

If this project sounds familiar, that's because it's a remake of the fantastic Wave Shield Talking Clock project by Phil B. That build was coded with Arduino and used the Wave Shield attached to an Arduino Uno. With the release of the RP2040 Prop-Maker Feather, it felt like a great time to revisit this idea.

Parts

Video of a white hand pressing a button to briefly turn an LED strip into white lights. Also wired up to the microcontroller are a servo motor and a speaker.
The Adafruit Feather series gives you lots of options for a small, portable, rechargeable microcontroller board. By picking a feather and stacking on a FeatherWing you can create...
Angled shot of RTC breakout.
 The datasheet for the DS3231 explains that this part is an "Extremely Accurate I²C-Integrated RTC/TCXO/Crystal". And, hey, it does exactly...
NeoPixel Stick with 8 x 5050 RGB LED
Make your own little LED strip arrangement with this stick of NeoPixel LEDs. We crammed 8 of the tiny 5050 (5mm x 5mm) smart RGB LEDs onto a PCB with mounting holes and a chainable...
Angled shot of 16mm black panel mount pushbutton.
OK, this item is pretty simple - it's a panel mount pushbutton. It's not that exciting, no LEDs, no bells & whistles. But we really like it anyways – look at that...
Mini Oval Speaker with pico blade connector
Hear the good news! This wee speaker is a great addition to any audio project where you need 8 ohm impedance and 1W or less of power. We particularly like...
Angled shot of STEMMA QT / Qwiic JST SH 4-pin Cable.
This 4-wire cable is a little over 100mm / 4" long and fitted with JST-SH female 4-pin connectors on both ends. Compared with the chunkier JST-PH these are 1mm pitch instead of...
1 x CR1220 Battery
3V Lithium Coin Cell Battery
1 x Heat shrink
Pack - 3/32" + 1/8" + 3/16" Diameters
1 x Silicone Wire
30AWG in Various Colors
  • DS3231 STEMMA QT port to Feather STEMMA QT port
  • Speaker + to Feather VO+ (red wire)
  • Speaker - to Feather VO- (black wire)
  • NeoPixel power to Feather V+ (red wire)
  • NeoPixel GND to Feather GND (black wire)
  • NeoPixel DATA IN to Feather PIXELOUT (yellow wire)
  • Button output to Feather BUTTON (green wire)
  • Button GND to Feather GND (black wire)

The clock may be assembled with 3D printed parts, described below. The case is a remix of the Adabot Toy Robot Friend. The head, electronics mount and head covering are updated for this design. 

The STL files can be downloaded directly here or from Printables.

You'll print the following parts:

  • 1x Adabot Head (light blue)
  • 2x Antenna (dark blue)
  • 2x Ear Cap (light blue)
  • 1x Electronics Mount (any color)
  • 1x Eye L (white)
  • 1x Eye R (white)
  • 1x Face Mask (black)
  • 1x Head Cover (light blue)
  • 1x Mouth (white)
  • 2x Pupil (black) 

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.

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

Save it wherever is convenient for you.

To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in blue above). Continue to hold the BOOT/BOOTSEL button until the RPI-RP2 drive appears!

If the drive does not appear, release all the buttons, and then repeat the process above.

You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.

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

You will see a new disk drive appear called RPI-RP2.

 

Drag the adafruit_circuitpython_etc.uf2 file to RPI-RP2.

The RPI-RP2 drive will disappear and a new disk drive called CIRCUITPY will appear.

That's it, you're done! :)

Safe Mode

You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.

Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.

Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.

Entering Safe Mode

To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)

In Safe Mode

If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.

If you connect to the serial console, you'll find the following message.

Auto-reload is off.
Running in safe mode! Not running saved code.

CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode.

Press any key to enter the REPL. Use CTRL-D to reload.

You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.

Flash Resetting UF2

If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RPI-RP2. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.

Once you've finished setting up your RP2040 Prop-Maker Feather with CircuitPython, you can access the code 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.

# SPDX-FileCopyrightText: 2023 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT

import os
import time
import board
import audiocore
import audiobusio
import audiomixer
import adafruit_ds3231
from digitalio import DigitalInOut, Direction
import neopixel
import keypad

# enable external power pin
# provides power to the external components
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True

# setup external button pin as a keypad object
key = keypad.Keys((board.EXTERNAL_BUTTON,), value_when_pressed=False, pull=True)

# neopixel mouth setup
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXELS, 8, auto_write=True, brightness=0.2)
blue = (0, 0, 255)
off = (0, 0, 0)
pixels.fill(off)

# rtc module
i2c = board.I2C()
rtc = adafruit_ds3231.DS3231(i2c)

# pylint: disable-msg=using-constant-test
if False:  # change to True if you want to set the time!
    #                     year, mon, date, hour, min, sec, wday, yday, isdst
    t = time.struct_time((2023, 8, 22, 16, 47, 00, 1, -1, -1))
    # you must set year, mon, date, hour, min, sec and weekday
    # yearday is not supported, isdst can be set but we don't do anything with it at this time
    print("Setting time to:", t)  # uncomment for debugging
    rtc.datetime = t
    print()
# pylint: enable-msg=using-constant-test

# sound arrays
bookend_sounds = []
hour_sounds = []
min_sounds = []
clock_queue = []
# bring in the audio files and put them into the different arrays
for filename in os.listdir('/clock_sounds'):
    if filename.lower().endswith('.wav') and not filename.startswith('.'):
        if filename.startswith('h'):
            hour_sounds.append("/clock_sounds/"+filename)
        elif filename.startswith('m'):
            min_sounds.append("/clock_sounds/"+filename)
        else:
            bookend_sounds.append("/clock_sounds/"+filename)
# sort the arrays alphabetically
bookend_sounds.sort()
hour_sounds.sort()
min_sounds.sort()
print(hour_sounds)
print(min_sounds)
print(bookend_sounds)

# i2s amp setup with mixer
audio = audiobusio.I2SOut(board.I2S_BIT_CLOCK, board.I2S_WORD_SELECT, board.I2S_DATA)
mixer = audiomixer.Mixer(voice_count=1, sample_rate=22050, channel_count=1,
                         bits_per_sample=16, samples_signed=True)
audio.play(mixer)
mixer.voice[0].level = 0.5

# function for queueing wave files
def open_audio(wavs, num):
    n = wavs[num]
    f = open(n, "rb")
    w = audiocore.WaveFile(f)
    clock_queue.append(w)
    return w

# queue boot up sound to play before going into the loop
boot_sound = open_audio(bookend_sounds, 2)

# function for gathering clock audio files
def prep_clock(hour_num, minute_num):
    # queues "the time is.."
    open_audio(bookend_sounds, 1)
    # convert 24 hour time from RTC to 12 hour
    if hour_num >= 12:
        # convert 24 hour to 12 hour
        if hour_num > 12:
            hour_num = hour_num - 12
        # set to PM
        ampm_num = 3
    # otherwise its the morning
    else:
        # set AM
        ampm_num = 0
    # queueing hour, single digit waves have a 0 before the number
    if hour_num <= 9:
        print(hour_num)
        h = hour_sounds.index(f'/clock_sounds/h0{hour_num}.wav')
    else:
        print(hour_num)
        h = hour_sounds.index(f'/clock_sounds/h{hour_num}.wav')
    # open wave file for the hour
    open_audio(hour_sounds, h)
    # if the minute is divisible by 10 (10, 20, 30, etc)
    if minute_num % 10 == 0:
        # check for on the hour (1:00, 2:00, etc)
        if minute_num == 0:
            m = min_sounds.index(f'/clock_sounds/m{minute_num}0.wav')
        else:
            m = min_sounds.index(f'/clock_sounds/m{minute_num}.wav')
        print(minute_num)
    # individual wave files exist for 1-19
    elif minute_num <= 19:
        print(minute_num)
        # if it's a single digit number, bring in the "oh" wave file
        if minute_num <= 9:
            mm = min_sounds.index('/clock_sounds/m0x.wav')
            open_audio(min_sounds, mm)
        m = min_sounds.index(f'/clock_sounds/m{minute_num}.wav')
    # otherwise, for minutes 21-59 that are not divisible by 10
    else:
        # we need to seperate the minutes digits
        digits = list(map(int, str(minute_num)))
        #print(digits)
        # get the tens spot (30, 40, etc)
        mm = min_sounds.index(f'/clock_sounds/m{digits[0]}x.wav')
        # add the wav to the array
        open_audio(min_sounds, mm)
        # get the ones spot (1, 2, etc)
        m = min_sounds.index(f'/clock_sounds/m{digits[1]}.wav')
        # combined it will say ex: "30...5"
    # add the wav to the array
    open_audio(min_sounds, m)
    # finish with am or pm
    open_audio(bookend_sounds, ampm_num)
# initial RTC read
t = rtc.datetime
print(t)
# say the time on boot
pixels.fill(blue)
prep_clock(t.tm_hour, t.tm_min)
for i in range(len(clock_queue)):
    mixer.voice[0].play(clock_queue[i], loop=False)
    while mixer.playing:
        pass
# clear the audio file queue
clock_queue.clear()
pixels.fill(off)

while True:
    # listen for button input
    event = key.events.get()
    if event:
        # if the button is pressed
        if event.pressed:
            # read from RTC module
            t = rtc.datetime
            # turn on neopixels
            pixels.fill(blue)
            # gather the audio files based on RTC reading
            prep_clock(t.tm_hour, t.tm_min)
            # play through each wave one by one
            for i in range(len(clock_queue)):
                mixer.voice[0].play(clock_queue[i], loop=False)
                while mixer.playing:
                    pass
            # afterwards clear out the queue
            clock_queue.clear()
            # turn off neopixels
            pixels.fill(off)

Upload the Code and Libraries to the RP2040 Prop-Maker Feather

After downloading the Project Bundle, plug your RP2040 Prop-Maker Feather 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 RP2040 Prop-Maker Feather's CIRCUITPY drive.

  • lib folder
  • clock_sounds folder
  • code.py

Your RP2040 Prop-Maker Feather CIRCUITPY drive should look like this after copying the lib folder, clock_sounds folder and the code.py file.

CIRCUITPY

How the CircuitPython Code Works

The code begins by enabling the external power pin on the Feather for the terminal block pins and instantiating the external button as a keypad object, the NeoPixels and the RTC module over I2C.

# enable external power pin
# provides power to the external components
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True

# setup external button pin as a keypad object
key = keypad.Keys((board.EXTERNAL_BUTTON,), value_when_pressed=False, pull=True)

# neopixel mouth setup
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXELS, 8, auto_write=True, brightness=0.2)
blue = (0, 0, 255)
off = (0, 0, 0)
pixels.fill(off)

# rtc module
i2c = board.I2C()
rtc = adafruit_ds3231.DS3231(i2c)

Set the Clock

Included in the code is an if statement that can reset the RTC module datetime. If you set it to True, you can edit the struct_time with your current time and save the code to save the settings to the RTC. Make sure to set it back to False so that you don't accidentally reset the time if the Feather reboots.

if False:  # change to True if you want to set the time!
    #                     year, mon, date, hour, min, sec, wday, yday, isdst
    t = time.struct_time((2023, 8, 22, 16, 47, 00, 1, -1, -1))
    # you must set year, mon, date, hour, min, sec and weekday
    # yearday is not supported, isdst can be set but we don't do anything with it at this time
    print("Setting time to:", t)  # uncomment for debugging
    rtc.datetime = t
    print()

Bring in the Waves

The wave files in the /clock_sounds folder are sorted into different arrays in the code. After that the files are sorted alphabetically.

# sound arrays
bookend_sounds = []
hour_sounds = []
min_sounds = []
clock_queue = []
# bring in the audio files and put them into the different arrays
for filename in os.listdir('/clock_sounds'):
    if filename.lower().endswith('.wav') and not filename.startswith('.'):
        if filename.startswith('h'):
            hour_sounds.append("/clock_sounds/"+filename)
        elif filename.startswith('m'):
            min_sounds.append("/clock_sounds/"+filename)
        else:
            bookend_sounds.append("/clock_sounds/"+filename)
# sort the arrays alphabetically
bookend_sounds.sort()
hour_sounds.sort()
min_sounds.sort()
print(hour_sounds)
print(min_sounds)
print(bookend_sounds)

I2S and Mixer

Audio playback occurs with the onboard I2S amp on the Feather. Audio files are passed through a Mixer object so that you can control volume through software.

# i2s amp setup with mixer
audio = audiobusio.I2SOut(board.I2S_BIT_CLOCK, board.I2S_WORD_SELECT, board.I2S_DATA)
mixer = audiomixer.Mixer(voice_count=1, sample_rate=22050, channel_count=1,
                         bits_per_sample=16, samples_signed=True)
audio.play(mixer)
mixer.voice[0].level = 0.5

Playback Functions

Two functions are used for playing back the audio files. The first, open_audio(), opens the selected audio file and adds it to the clock_queue array.

# function for queueing wave files
def open_audio(wavs, num):
    n = wavs[num]
    f = open(n, "rb")
    w = audiocore.WaveFile(f)
    clock_queue.append(w)
    return w

The next function, prep_clock(), takes the time from the RTC module and searches the audio file arrays for the matching wave files. f strings are used to find a matching audio file. Then, the index location of that audio file is passed to the open_audio() function to add the chosen file to the clock_queue array.

# function for gathering clock audio files
def prep_clock(hour_num, minute_num):
    # queues "the time is.."
    open_audio(bookend_sounds, 1)
    # convert 24 hour time from RTC to 12 hour
    if hour_num > 12:
        hour_num = hour_num - 12
        # set PM
        ampm_num = 3
    # otherwise its the morning
    else:
        # set AM
        ampm_num = 0
    # queueing hour, single digit waves have a 0 before the number
    if hour_num <= 9:
        print(hour_num)
        h = hour_sounds.index(f'/clock_sounds/h0{hour_num}.wav')
    else:
        print(hour_num)
        h = hour_sounds.index(f'/clock_sounds/h{hour_num}.wav')
    # open wave file for the hour
    open_audio(hour_sounds, h)
    # if the minute is divisible by 10 (10, 20, 30, etc)
    if minute_num % 10 == 0:
        # check for on the hour (1:00, 2:00, etc)
        if minute_num == 0:
            m = min_sounds.index(f'/clock_sounds/m{minute_num}0.wav')
        else:
            m = min_sounds.index(f'/clock_sounds/m{minute_num}.wav')
        print(minute_num)
    # individual wave files exist for 1-19
    elif minute_num <= 19:
        print(minute_num)
        # if it's a single digit number, bring in the "oh" wave file
        if minute_num <= 9:
            mm = min_sounds.index('/clock_sounds/m0x.wav')
            open_audio(min_sounds, mm)
        m = min_sounds.index(f'/clock_sounds/m{minute_num}.wav')
    # otherwise, for minutes 21-59 that are not divisible by 10
    else:
        # we need to seperate the minutes digits
        digits = list(map(int, str(minute_num)))
        #print(digits)
        # get the tens spot (30, 40, etc)
        mm = min_sounds.index(f'/clock_sounds/m{digits[0]}x.wav')
        # add the wav to the array
        open_audio(min_sounds, mm)
        # get the ones spot (1, 2, etc)
        m = min_sounds.index(f'/clock_sounds/m{digits[1]}.wav')
        # combined it will say ex: "30...5"
    # add the wav to the array
    open_audio(min_sounds, m)
    # finish with am or pm
    open_audio(bookend_sounds, ampm_num)

The Loop

In the loop, the code listens for a keypad event from the button. When the button is pressed, the NeoPixels turn blue to illuminate Adabot's mouth. The RTC module is read and its hour and minute reading are passed to the prep_clock function to prepare the audio files for playback. Then, each queued audio file is played one by one. To finish up, the clock_queue array is cleared and the NeoPixels are turned off.

while True:
    # listen for button input
    event = key.events.get()
    if event:
        # if the button is pressed
        if event.pressed:
            # read from RTC module
            t = rtc.datetime
            # turn on neopixels
            pixels.fill(blue)
            # gather the audio files based on RTC reading
            prep_clock(t.tm_hour, t.tm_min)
            # play through each wave one by one
            for i in range(len(clock_queue)):
                mixer.voice[0].play(clock_queue[i], loop=False)
                while mixer.playing:
                    pass
            # afterwards clear out the queue
            clock_queue.clear()
            # turn off neopixels
            pixels.fill(off)

All of the wires should be approximately 4 inches/10 cm in length.

Speaker

Solder wire to each of the speaker's wires to extend them.

Add heat shrink over the solder joints. The pieces should be about 0.5 inches/1.2 cm long or long enough to comfortably cover your solder joint.

Button

Solder a piece of wire to each of the button pins.

NeoPixel Stick

Tin the DIN, 5VDC and GND pads on the back of the NeoPixel stick.

Solder wire to the DIN, 5VDC and GND pads on the NeoPixel stick.

That wraps up all of the soldering. Now its time to assemble the clock.

Head Parts

Gather up the parts to build the head. Parts include head case, eyes, pupils, mouth, ear caps and antennas.

Install Eyes & Mouth

Press fit the two eyes and mouth parts through the corresponding cutouts inside the head case.

Ear & Antenna

Insert the antenna through the peg of the ear cap. Repeat for the second ear cap and antenna.

Install Ears

Press fit the ear cap through the ear of the head case. Repeat for the second set of ear cap and antenna.

Pupils

Add the two pupils to the eyes by adhering with either super glue or double-sided tape.

Face Mask

A separate "face mask" printed in a black colored filament will block the light from the NeoPixel LEDs from bleeding into the rest of the head.

Face Mask Install

Apply super glue or double-sided tape to the surface of the face mask and press fit into the head.  

Mount the NeoPixel Stick

Attach the NeoPixel stick to the electronics mount with M2.5 screws and nuts.

RTC Breakout

Insert a CR1220 coin cell battery into the RTC breakout.

Attach M2.5 stand-offs with M2.5 screws into the mounting holes above the NeoPixel stick on the electronics mount.

Attach the RTC breakout to the M2.5 stand-offs with M2.5 screws.

Plug in a STEMMA QT cable to the RTC breakout.

Speaker and Button

Peel off the paper covering the adhesive on the front of the oval speaker. Insert it into the mount at the top of Adabot's head.

Mount the button through the hole at the top of Adabot's head.

Mount the Mount

Insert the electronics mount into Adabot's head. Line up the mount's holes with the holes at the bottom of the head. Secure the mount with M2.5 screws and stand-offs in the mounting holes closest to the front of the head.

Bring in the Feather

Insert M2.5 stand-offs into the mounting holes on the Feather. Secure them with M2.5 nuts. Plug the STEMMA QT cable from the RTC breakout into the STEMMA QT port on the Feather. Insert the wires from the components into the terminal screw block on the Feather:

  • Speaker positive to Feather VO+
  • Speaker negative to Feather VO-
  • Button output to Feather Btn
  • Button GND to Feather G
  • NeoPixel GND to Feather G
  • NeoPixel DIN to Feather Neo
  • NeoPixel 5V to Feather 5V

The ground wires from the button and NeoPixel stick will both be inserted into the G terminal screw block together.

Mount the Feather to the electronics mount using M2.5 screws. The USB port should be lined up with the USB cut-out on the side of Adabot's head. The head has a lot of room for the component wires to be pushed in.

In Closing

Finish up the build by closing up Adabot's head with the back lid.

And that completes your friendly robot clock friend!

This guide was first published on Aug 28, 2023. It was last updated on Jul 23, 2024.