I was looking for a clock that would tell the time in words rather than just hands (analog) or numbers (digital). I found a few for sale on places like Amazon and eBay, but none of those particularly stood out, so I decided to create a customizable one myself.
I designed and built two variants. Both use the same RGB matrix panel. Both use CircuitPython on the ESP32-S3, but one uses the Matrix Portal S3, and the other uses a standalone Feather ESP32-S3 with an Adafruit RGB Matrix Featherwing Kit. The Matrix Portal version requires no soldering; the Feather version requires some simple soldering. I will show both here.
The text is left justified, vertically centered on the display and each line is in a random color which changes every time the minute changes. Since you control the CircuitPython code, you can change this formatting any way you choose, e.g. same color lines, individually horizontally centered lines, etc.
Common Hardware
- Any of the 64x64 RGB LED matrix panels will work. The only differences in the result will be the panel dimensions and appearance. I chose the 2mm pitch version because I liked the smaller panel size and less space between LEDs. The 2.5 mm pitch also looks good. Just choose one that meets your requirements.
- Optional - magnetic feet for matrix panel
The Matrix Portal S3 Version
- Adafruit Matrix Portal S3
- Any 5V, 2A or higher USB power supply and cable with a USB type C connector. For example:
- Assembly
- Prep the Matrix Portal. See: https://learn.adafruit.com/adafruit-matrixportal-s3/prep-the-matrixportal
- Using a USB cable, install CircuitPython on the Matrix Portal. See: https://learn.adafruit.com/adafruit-matrixportal-s3/install-circuitpython
Be sure to update the settings.toml file with your WiFi SSID, WiFi password, and timezone information.
CIRCUITPY_WIFI_SSID = "xxxxxx" CIRCUITPY_WIFI_PASSWORD = "xxxxxx" CIRCUITPY_WEB_API_PASSWORD = "yyyyyy" CIRCUITPY_WEB_API_PORT=80 # UTC_OFFSET, if present, will override TZ, no worldtimeapi.org API query will be done, and no DST adjustment #UTC_OFFSET=-25200 TZ="America/Phoenix"
- Update your code.py to the following:
# SPDX-FileCopyrightText: 2023 Frederick M Meyer # # SPDX-License-Identifier: MIT import ipaddress import ssl import wifi import socketpool import adafruit_requests import random import os import time import rtc import adafruit_ntp import adafruit_datetime import displayio import framebufferio import rgbmatrix import board import digitalio import terminalio import adafruit_display_text as adt import adafruit_display_text.label as adtl UTC_OFFSET = os.getenv('UTC_OFFSET') TZ = os.getenv('TZ') QUARTER = "Quarter" HALF = "Half" PAST = "Past" UNTIL = "Until" OCLOCK = "O'clock" NOON = "Noon" MIDNIGHT = "Midnight" MINUTE = "Minute" SINGLE_DIGITS = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"] TEN_PLUS = ["Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"] TENS = ["Twenty", "Thirty", "Fourty", "Fifty"] #time.sleep(15) led = digitalio.DigitalInOut(board.LED) led.direction = digitalio.Direction.OUTPUT led.value = False displayio.release_displays() # Wifi details are in settings.toml file print("My MAC addr:", [hex(i) for i in wifi.radio.mac_address]) print("Connecting to %s"%os.getenv("CIRCUITPY_WIFI_SSID")) wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) print("Connected to %s!"%os.getenv("CIRCUITPY_WIFI_SSID")) print("My IP address is", wifi.radio.ipv4_address) pool = socketpool.SocketPool(wifi.radio) if UTC_OFFSET is None: requests = adafruit_requests.Session(pool, ssl.create_default_context()) response = requests.get("http://worldtimeapi.org/api/timezone/" + TZ) response_as_json = response.json() UTC_OFFSET = response_as_json["raw_offset"] + response_as_json["dst_offset"] ntp = adafruit_ntp.NTP(pool, server="us.pool.ntp.org", tz_offset=UTC_OFFSET // 3600) rtc.RTC().datetime = ntp.datetime last_minute_displayed = -1 def format_hour(hour_num): hour = hour_num % 24 if hour == 0: o_hour = MIDNIGHT elif hour == 12: o_hour = NOON else: if hour > 12: hour -= 12 elif hour == 0: hour = 12 if hour < 10: o_hour = SINGLE_DIGITS[hour - 1] else: o_hour = TEN_PLUS[hour - 10] return hour, o_hour COLOR_VALUES = [0, 128, 255] # Brighter - Possible color values used for random 0..2 selection #COLOR_VALUES = [0, 80, 160] # Dimmer - Possible color values used for random 0..2 selection #COLOR_VALUES = [0, 64, 128] # Dimmer - Possible color values used for random 0..2 selection def pick_random_color(): # Pick a random color for each line and add it to the display r = COLOR_VALUES[random.randint(0, 2)] g = COLOR_VALUES[random.randint(0, 2)] b = COLOR_VALUES[random.randint(0, 2)] if not (r | g | b): r = g = b = COLOR_VALUES[2] # Set to white if black result return (r<<16|g<<8|b) matrix = rgbmatrix.RGBMatrix( width=64, height=64, bit_depth=2, rgb_pins=[ board.MTX_R1, board.MTX_G1, board.MTX_B1, board.MTX_R2, board.MTX_G2, board.MTX_B2 ], addr_pins=[ board.MTX_ADDRA, board.MTX_ADDRB, board.MTX_ADDRC, board.MTX_ADDRD, board.MTX_ADDRE ], clock_pin=board.MTX_CLK, latch_pin=board.MTX_LAT, output_enable_pin=board.MTX_OE ) display = framebufferio.FramebufferDisplay(matrix, auto_refresh=True) display.rotation = 0 while True: # time.struct_time(tm_year=2023, tm_mon=2, tm_mday=23, tm_hour=14, tm_min=6, tm_sec=29, tm_wday=3, tm_yday=54, tm_isdst=0) lt = time.localtime() hour = lt.tm_hour min = lt.tm_min #hour = 12 # For testing #min = 00 # For testing if last_minute_displayed != min: last_minute_displayed = min if min <= 30: o_min_half = "Past" hour, o_hour = format_hour(hour) else: o_min_half = "Until" min = 60 - min hour, o_hour = format_hour(hour + 1) if min == 15: o_min = "Quarter" elif min == 30: o_min = "Half" elif min == 0: o_min = "" o_min_half = "" elif min < 10: o_min = SINGLE_DIGITS[min - 1] elif min < 20: o_min = TEN_PLUS[min - 10] else: o_min = TENS[(min // 10) - 2] if min % 10: o_min += " " + SINGLE_DIGITS[(min % 10) - 1] if o_min not in [QUARTER, HALF, ""]: o_min += " " + MINUTE if min != 1: o_min += "s" if min > 0 or o_hour in [MIDNIGHT, NOON]: txt = (o_min + " " + o_min_half + " " + o_hour).strip() else: txt = (o_min + " " + o_min_half + " " + o_hour + " " + OCLOCK).strip() text_list = adt.wrap_text_to_pixels(txt, 60, font=terminalio.FONT) total_height = 0 max_width = 0 line_list = [] for w in text_list: line = adtl.Label( terminalio.FONT, color=pick_random_color(), text=w, scale=1) line_list.append(line) zx, zy, zwidth, zheight = line.bounding_box total_height += zheight max_width = max(max_width, zwidth) xwork = ((60 - max_width) // 2) + 2 ywork = ((60 - total_height) // 2) + 2 + 6 current_y = ywork g = displayio.Group() for l in line_list: l.x = xwork l.y = current_y zx, zy, zwidth, zheight = l.bounding_box current_y += zheight g.append(l) display.root_group=g time.sleep(1)
- Install the following modules into the lib folder:
- Connect the Matrix Portal and power connector to the RGB LED panel and then connect the power supply to the Matrix Portal. If there is a pin on the frame of the panel under the Matrix Portal that prevents it from connecting fully, use angle cutters to remove the pin.
The standalone ESP32-S3 Feather Version
- Any of the Adafruit ESP32-S3 Feathers should work. I decided that the best fit would be one of these two. I chose the first for no specific reason.
-or-
- Because I chose an ESP32-S3 in the Feather form factor as a processor, I needed a compatible Featherwing format for the interface to the RGB matrix panel. Be careful, there are multiple versions of these kits based on the processor type it supports.
- Any 5V, 2A or greater power supply with a 'standard' 5.5mm OD, 2.1mm ID positive tip connector to plug into the socket on the Featherwing adapter. For example, I used a 4-amp power supply in case I ever decide to put up a display that lights more pixels and has a higher power draw, but 2A should be enough to power this build.
- The ESP32-S3 stacks on top of the Featherwing, so we need some female headers.
- A 6-inch piece of 18-22AWG solid-core hook-up wire
- Soldering Iron and rosin-core electronics solder
- Angle cutters
- Assembly
- Prep RGB Matrix Featherwing. See: https://learn.adafruit.com/rgb-matrix-featherwing
Be sure you are soldering all the components on the proper side of the PCB and facing in the correct direction. Before soldering any other parts, you will need to install a jumper wire, as seen in the picture. The Featherwing comes from the factory with 4 address lines ("A"-"D"). This build uses a 64x64 pixel panel which requires 5 address lines, so you have to add an additional address line. This wire will provide the path for the "E" address line on Pin 8 of the HUB-75 connector. (If your panel uses Pin 16, move the wire to connect on that pin instead. Adafruit panels all use Pin 8.)
You may use either the male OR female HUB-75 connector, but only one can be soldered on the PCB, so choose the one that meets your needs. They mount on different sides of the board, so again, be sure you're soldering on the correct side and facing in the correct direction (male connector has a notch in it).
- Solder the headers onto the Feather ESP32-S3 facing down and soldered on the top.
- Using a USB cable, install CircuitPython on the ESP32-S3. See: https://learn.adafruit.com/adafruit-esp32-s3-feather/circuitpython
Be sure to update the settings.toml file with your WiFi SSID, WiFi password, and timezone information.
CIRCUITPY_WIFI_SSID = "xxxxxx" CIRCUITPY_WIFI_PASSWORD = "xxxxxx" CIRCUITPY_WEB_API_PASSWORD = "yyyyyy" CIRCUITPY_WEB_API_PORT=80 # UTC_OFFSET, if present, will override TZ, no worldtimeapi.org API query will be done, and no DST adjustment #UTC_OFFSET=-25200 TZ="America/Phoenix"
- Update your code.py to the following:
# SPDX-FileCopyrightText: 2023 Frederick M Meyer # # SPDX-License-Identifier: MIT import ipaddress import ssl import wifi import socketpool import adafruit_requests import random import os import time import rtc import adafruit_ntp import adafruit_datetime import displayio import framebufferio import rgbmatrix import board import digitalio import terminalio import adafruit_display_text as adt import adafruit_display_text.label as adtl UTC_OFFSET = os.getenv('UTC_OFFSET') TZ = os.getenv('TZ') QUARTER = "Quarter" HALF = "Half" PAST = "Past" UNTIL = "Until" OCLOCK = "O'clock" NOON = "Noon" MIDNIGHT = "Midnight" MINUTE = "Minute" SINGLE_DIGITS = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"] TEN_PLUS = ["Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"] TENS = ["Twenty", "Thirty", "Fourty", "Fifty"] #time.sleep(15) neopix_pwr = digitalio.DigitalInOut(board.NEOPIXEL_POWER) neopix_pwr.direction = digitalio.Direction.OUTPUT neopix_pwr.value = False displayio.release_displays() # Wifi details are in settings.toml file print("My MAC addr:", [hex(i) for i in wifi.radio.mac_address]) print("Connecting to %s"%os.getenv("CIRCUITPY_WIFI_SSID")) wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) print("Connected to %s!"%os.getenv("CIRCUITPY_WIFI_SSID")) print("My IP address is", wifi.radio.ipv4_address) pool = socketpool.SocketPool(wifi.radio) if UTC_OFFSET is None: requests = adafruit_requests.Session(pool, ssl.create_default_context()) response = requests.get("http://worldtimeapi.org/api/timezone/" + TZ) response_as_json = response.json() UTC_OFFSET = response_as_json["raw_offset"] + response_as_json["dst_offset"] ntp = adafruit_ntp.NTP(pool, server="us.pool.ntp.org", tz_offset=UTC_OFFSET // 3600) rtc.RTC().datetime = ntp.datetime last_minute_displayed = -1 def format_hour(hour_num): hour = hour_num % 24 if hour == 0: o_hour = MIDNIGHT elif hour == 12: o_hour = NOON else: if hour > 12: hour -= 12 elif hour == 0: hour = 12 if hour < 10: o_hour = SINGLE_DIGITS[hour - 1] else: o_hour = TEN_PLUS[hour - 10] return hour, o_hour COLOR_VALUES = [0, 128, 255] # Brighter - Possible color values used for random 0..2 selection #COLOR_VALUES = [0, 80, 160] # Dimmer - Possible color values used for random 0..2 selection #COLOR_VALUES = [0, 64, 128] # Dimmer - Possible color values used for random 0..2 selection def pick_random_color(): # Pick a random color for each line and add it to the display r = COLOR_VALUES[random.randint(0, 2)] g = COLOR_VALUES[random.randint(0, 2)] b = COLOR_VALUES[random.randint(0, 2)] if not (r | g | b): r = g = b = COLOR_VALUES[2] # Set to white if black result return (r<<16|g<<8|b) matrix = rgbmatrix.RGBMatrix( width=64, height=64, bit_depth=2, rgb_pins=[board.D6, board.D5, board.D9, board.D11, board.D10, board.D12], addr_pins=[board.A5, board.A4, board.A3, board.A2, board.A1], clock_pin=board.D13, latch_pin=board.RX, output_enable_pin=board.TX) display = framebufferio.FramebufferDisplay(matrix, auto_refresh=True) display.rotation = 0 while True: lt = time.localtime() hour = lt.tm_hour min = lt.tm_min #hour = 12 # For testing #min = 00 # For testing if last_minute_displayed != min: last_minute_displayed = min if min <= 30: o_min_half = "Past" hour, o_hour = format_hour(hour) else: o_min_half = "Until" min = 60 - min hour, o_hour = format_hour(hour + 1) if min == 15: o_min = "Quarter" elif min == 30: o_min = "Half" elif min == 0: o_min = "" o_min_half = "" elif min < 10: o_min = SINGLE_DIGITS[min - 1] elif min < 20: o_min = TEN_PLUS[min - 10] else: o_min = TENS[(min // 10) - 2] if min % 10: o_min += " " + SINGLE_DIGITS[(min % 10) - 1] if o_min not in [QUARTER, HALF, ""]: o_min += " " + MINUTE if min != 1: o_min += "s" if min > 0 or o_hour in [MIDNIGHT, NOON]: txt = (o_min + " " + o_min_half + " " + o_hour).strip() else: txt = (o_min + " " + o_min_half + " " + o_hour + " " + OCLOCK).strip() text_list = adt.wrap_text_to_pixels(txt, 60, font=terminalio.FONT) total_height = 0 max_width = 0 line_list = [] for w in text_list: line = adtl.Label( terminalio.FONT, color=pick_random_color(), text=w, scale=1) line_list.append(line) zx, zy, zwidth, zheight = line.bounding_box total_height += zheight max_width = max(max_width, zwidth) xwork = ((60 - max_width) // 2) + 2 ywork = ((60 - total_height) // 2) + 2 + 6 current_y = ywork g = displayio.Group() for l in line_list: l.x = xwork l.y = current_y zx, zy, zwidth, zheight = l.bounding_box current_y += zheight g.append(l) display.root_group=g time.sleep(1)
- Install the following modules into the lib folder:
- Connect the Featherwing and power connector to the RGB LED panel and then connect the power supply to the Featherwing. A USB cable is not needed. If you plug the Featherwing directly into the panel, the HUB-75 connector has no tab on it to show the proper direction, be sure it's facing in the correct direction. See picture.