Overview
Turn your MatrixPortal S3 into a beautiful analog clock with animated backgrounds that change throughout the day.
The clock displays hour and minute hands over a flowing gradient background, inspired by blurry LED art gallery sculptures.
The background shifts through four palettes — morning, daytime, evening, and nighttime — each with its own color scheme that transitions automatically based on the time of day.
A slow wave animation ripples through the gradient, giving it a calming, water-like motion.
At nighttime, twinkling yellow stars appear scattered across the dial.
A soft glow halo surrounds the clock hands, and plus-shaped hour markers make it easy to read at a glance.
The whole display is designed to sit behind a diffuser panel for a blurred, dreamy look — inspired by LED artwork encased in thick resin.
We think this makes a great desk piece or wall-mounted accent clock.
It also works as a playful way to help kids learn how to tell time — the colorful backgrounds and animated hands keep it engaging, and the large, simple hour markers make it easy to follow.
Page last edited March 17, 2026
Text editor powered by tinymce.
Circuit Diagram
This provides a visual reference for wiring of the components.
The Matrix Portal is connected to the Matrix Display through the included IDC cable.
The IDC cable plugs into the JIN HUB75 port on the display. The other end plugs into the socket header on top of the MatrixPortal.
(The Matrix Portal mounts to the printed plate to position the board so the buttons can be pressed from the side of the display.)
Power
The power cable plugs into the VCC and GND header pins, where the clip can grip to the nubs next to the pins.
Use the included machine screws to fasten to the red wire to the 5V terminal
The black wire to the GND terminals on the Matrix Portal.
Page last edited March 17, 2026
Text editor powered by tinymce.
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.
Set up CircuitPython Quick Start!
Follow this quick step-by-step for super-fast Python power :)
Further Information
For more detailed info on installing CircuitPython, check out Installing CircuitPython.
Click the link above and download the latest UF2 file.
Download and save it to your desktop (or wherever is handy).
Plug your MatrixPortal S3 into your computer using a known-good USB cable.
A lot of people end up using charge-only USB cables and it is very frustrating! So make sure you have a USB cable you know is good for data sync.
Click the Reset button (indicated by the green arrow) on your board. When you see the NeoPixel RGB LED (indicated by the magenta arrow) turn purple, press it again. At that point, the NeoPixel should turn green. If it turns red, check the USB cable, try another USB port, etc.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
You will see a new disk drive appear called MATRXS3BOOT.
Drag the adafruit_circuitpython_etc.uf2 file over to MATRXS3BOOT.
Page last edited March 17, 2026
Text editor powered by tinymce.
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 WiFi SSID and SSID password. 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.
WiFi Credentials
The most minimal settings.toml file must contain your WiFi SSID and password, as that is the minimum required to connect to WiFi and sync the clock via NTP. Copy this example, paste it into your settings.toml, and update the values with your network credentials.
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"
-
CIRCUITPY_WIFI_SSID— Your WiFi network name -
CIRCUITPY_WIFI_PASSWORD— Your WiFi password
If these values are missing or blank, the clock will skip WiFi and enter the manual time-set mode on boot
Timezone and Daylight Saving Time
The clock uses your standard UTC offset along with automatic US Daylight Saving Time computation to keep the time accurate year-round. Add these lines to your settings.toml file.
TZ_STD_OFFSET = "-5" DST_AUTO = "true"
-
TZ_STD_OFFSET— Your standard UTC offset without daylight savings time (DST) applied. Common US values:-5for Eastern,-6for Central,-7for Mountain,-8for Pacific. Use0for GMT or+1for CET. -
DST_AUTO— Set to"true"to automatically add +1 hour during US DST (second Sunday in March through first Sunday in November in much of the US). Set to"false"for non-US timezones or locations that do not observe DST.
Display Rotation
The display can be rotated to accommodate different mounting orientations. This sets the default rotation on boot.
DISPLAY_ROTATION = "90"
-
DISPLAY_ROTATION— The rotation angle in degrees. Accepted values are0,90,180, and270.
The rotation determines which direction the USB cable points when the clock is mounted.
-
0— USB cable points left -
90— USB cable points down (recommended for wall mounting) -
180— USB cable points right -
270— USB cable points up
You can also cycle through rotations at run time with a long press on the UP button.
Complete settings.toml File
Here is the complete settings.toml file with all available options. Copy this into the root of your CIRCUITPY drive and update the values for your setup.
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password" TZ_STD_OFFSET = "-5" DST_AUTO = "true" DISPLAY_ROTATION = "90"
settings.toml Tips
All values in the settings.toml file must be enclosed in double quotes. This includes numeric values like TZ_STD_OFFSET and DISPLAY_ROTATION. The code converts them to the appropriate type internally.
If you run into connectivity issues, verify that the values in your settings.toml file match the variable names expected in code.py. The required names are: CIRCUITPY_WIFI_SSID, CIRCUITPY_WIFI_PASSWORD, TZ_STD_OFFSET, DST_AUTO, and DISPLAY_ROTATION.
You can leave out the WiFi credentials entirely to use the clock in offline mode with the manual time-set feature. The timezone and rotation settings still apply in offline mode if present.
Page last edited March 17, 2026
Text editor powered by tinymce.
Code
Code the Clock
Once you've finished setting up your MatrixPortal S3 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: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Analog Clock for MatrixPortal S3 with 32x32 RGB LED Matrix.
Displays an analog clock with hour and minute hands over a
gradient background that shifts through four palettes across
the day. Color inspiration drawn from the Florida Arts License
Plate. Designed for a diffused display aesthetic inspired by
LED artwork behind thick resin.
Hardware:
- Adafruit MatrixPortal S3 (ESP32-S3, 8 MB flash, 2 MB SRAM)
- 32x32 RGB LED Matrix — 4mm pitch (Adafruit PID 607)
- No Address E jumper needed (4 address lines only)
Boot flow:
- If WiFi creds found in settings.toml -> NTP sync ->
compute DST from date -> clock
- If no WiFi creds or connection fails -> display message ->
manual time set with UP/DOWN buttons -> clock
Libraries required in /lib:
adafruit_ntp.mpy
"""
import math
import os
import time
import board
import digitalio
import displayio
import framebufferio
import rgbmatrix
import rtc
# ------------------------------------------------------------------ #
# Display dimensions and clock geometry #
# ------------------------------------------------------------------ #
WIDTH = 32
HEIGHT = 32
CENTER_X = 16
CENTER_Y = 16
CLOCK_RADIUS = 14
MINUTE_HAND_LEN = 11
HOUR_HAND_LEN = 7
# ------------------------------------------------------------------ #
# Time-sync interval (seconds) and day-period boundaries (24h) #
# ------------------------------------------------------------------ #
SYNC_INTERVAL = 3600
MORNING_HOUR = 6
DAY_HOUR = 12
EVENING_HOUR = 17
NIGHT_HOUR = 20
# Wave speed settings (toggled via UP button)
WAVE_SPEED = 0.9 # calm (default)
WAVE_SPEED_FAST = 1.8 # energetic
# Display rotation (0, 90, 180, 270) — set for USB cable direction.
# 0=USB left, 90=USB down, 180=USB right, 270=USB up.
# Long-press UP button to cycle through rotations at runtime.
ROTATIONS = (0, 90, 180, 270)
DEFAULT_ROTATION = int(os.getenv("DISPLAY_ROTATION") or "90")
# Timezone — US DST computed automatically from NTP date.
# Standard offset from UTC (no DST). Code adds +1 during DST.
# Examples: -5 for Eastern, -6 for Central, -7 for Mountain,
# -8 for Pacific. Set DST_AUTO = "false" to disable.
TZ_STD_OFFSET = int(os.getenv("TZ_STD_OFFSET") or "-5")
DST_AUTO = (os.getenv("DST_AUTO") or "true").lower() == "true"
# ------------------------------------------------------------------ #
# Color palettes — (top, middle, bottom) RGB tuples per period #
# ------------------------------------------------------------------ #
GRAD_MORNING = (
(0x73, 0x61, 0xB5), (0xF2, 0xBC, 0x42), (0x5D, 0x6A, 0x2F)
)
GRAD_DAY = (
(0x31, 0x6B, 0xB4), (0xC4, 0xDC, 0xF4), (0xD7, 0xB9, 0x96)
)
GRAD_EVENING = (
(0x63, 0x9B, 0xAC), (0xB9, 0x65, 0x65), (0x7C, 0xA0, 0xD2)
)
GRAD_NIGHT = (
(0xCD, 0x41, 0x7E), (0x64, 0x29, 0x69), (0x2F, 0x6E, 0x99)
)
# Hand colours — near-black to contrast against all gradients
# Pure 0x000000 causes flicker at low bit_depth; a slight
# dark value keeps LEDs minimally energized and smooth.
HAND_MORNING = 0x00C6FF
HAND_DAY = 0xDD5500
HAND_EVENING = 0xEAFF00
HAND_NIGHT = 0xFFDD00
# 5-minute markers — match hand colours per period
MARKER_MORNING = 0x00C6FF
MARKER_DAY = 0xDD5500
MARKER_EVENING = 0xEAFF00
MARKER_NIGHT = 0xFFDD00
# Star positions (x, y, phase_offset) — scaled for 32x32
STARS = [
(3, 3, 0.0), (27, 5, 0.8), (2, 24, 1.5),
(28, 27, 2.3), (11, 2, 3.0), (22, 29, 3.8),
(8, 10, 4.2), (24, 12, 5.0), (9, 22, 5.7),
(23, 21, 0.4),
]
# ------------------------------------------------------------------ #
# Palette index assignments for the foreground overlay #
# ------------------------------------------------------------------ #
IDX_CLEAR = 0
IDX_HAND = 1
IDX_MARKER = 2
IDX_CARDINAL = 3
IDX_CENTER = 4
IDX_GLOW = 15 # hand glow — blended colour, last index
IDX_STAR_BASE = 5 # indices 5..14 for ten stars
# ================================================================== #
# Mini 3x5 pixel font for setup screens #
# ================================================================== #
# Each char is a tuple of 5 rows, each row is 3 bits wide.
FONT_3X5 = {
'0': (0b111, 0b101, 0b101, 0b101, 0b111),
'1': (0b010, 0b110, 0b010, 0b010, 0b111),
'2': (0b111, 0b001, 0b111, 0b100, 0b111),
'3': (0b111, 0b001, 0b111, 0b001, 0b111),
'4': (0b101, 0b101, 0b111, 0b001, 0b001),
'5': (0b111, 0b100, 0b111, 0b001, 0b111),
'6': (0b111, 0b100, 0b111, 0b101, 0b111),
'7': (0b111, 0b001, 0b010, 0b010, 0b010),
'8': (0b111, 0b101, 0b111, 0b101, 0b111),
'9': (0b111, 0b101, 0b111, 0b001, 0b111),
':': (0b000, 0b010, 0b000, 0b010, 0b000),
'N': (0b101, 0b111, 0b111, 0b101, 0b101),
'O': (0b111, 0b101, 0b101, 0b101, 0b111),
'W': (0b101, 0b101, 0b111, 0b111, 0b101),
'I': (0b111, 0b010, 0b010, 0b010, 0b111),
'F': (0b111, 0b100, 0b110, 0b100, 0b100),
'S': (0b111, 0b100, 0b111, 0b001, 0b111),
'E': (0b111, 0b100, 0b111, 0b100, 0b111),
'T': (0b111, 0b010, 0b010, 0b010, 0b010),
'H': (0b101, 0b101, 0b111, 0b101, 0b101),
'R': (0b111, 0b101, 0b111, 0b110, 0b101),
'M': (0b101, 0b111, 0b111, 0b101, 0b101),
' ': (0b000, 0b000, 0b000, 0b000, 0b000),
'U': (0b101, 0b101, 0b101, 0b101, 0b111),
'P': (0b111, 0b101, 0b111, 0b100, 0b100),
'D': (0b110, 0b101, 0b101, 0b101, 0b110),
}
def draw_char_3x5(bmp, char, pos_x, pos_y, pal_idx):
"""Draw a 3x5 character into a bitmap."""
glyph = FONT_3X5.get(char.upper())
if glyph is None:
return
for row in range(5):
for col in range(3):
if glyph[row] & (1 << (2 - col)):
p_x = pos_x + col
p_y = pos_y + row
if 0 <= p_x < WIDTH and 0 <= p_y < HEIGHT:
bmp[p_x, p_y] = pal_idx
def draw_text_3x5(bmp, text, pos_x, pos_y, pal_idx):
"""Draw a string using the 3x5 font, 4px per char."""
for i, char in enumerate(text):
draw_char_3x5(bmp, char, pos_x + i * 4, pos_y, pal_idx)
def center_text_x(text):
"""Return X position to center text on display."""
text_width = len(text) * 4 - 1
return (WIDTH - text_width) // 2
# ================================================================== #
# Helper functions #
# ================================================================== #
def _nth_weekday(year, month, weekday, nth):
"""Return day-of-month for the nth weekday in a month.
weekday: 0=Mon ... 6=Sun. nth: 1=first, 2=second, etc.
"""
# Day-of-week for the 1st of the month (time.mktime unavail,
# so use Zeller-like formula via time.struct_time)
first = time.mktime(time.struct_time((
year, month, 1, 0, 0, 0, 0, -1, -1
)))
first_wday = time.localtime(first).tm_wday
# Days until the target weekday
diff = (weekday - first_wday) % 7
return 1 + diff + (nth - 1) * 7
def compute_dst_offset(std_offset):
"""Return UTC offset including US DST if active.
US DST: 2nd Sunday in March 2:00 AM -> 1st Sunday in
November 2:00 AM. Adds +1 hour during DST.
"""
if not DST_AUTO:
return std_offset
cur = time.localtime()
year = cur.tm_year
dst_start_day = _nth_weekday(year, 3, 6, 2)
dst_end_day = _nth_weekday(year, 11, 6, 1)
month = cur.tm_mon
day = cur.tm_mday
hour = cur.tm_hour
# Determine if current date/time falls in DST window
in_dst = False
if 3 < month < 11:
in_dst = True
elif month == 3:
in_dst = (day > dst_start_day
or (day == dst_start_day and hour >= 2))
elif month == 11:
in_dst = (day < dst_end_day
or (day == dst_end_day and hour < 2))
return std_offset + 1 if in_dst else std_offset
def lerp_color(color_a, color_b, frac):
"""Blend two (r, g, b) tuples by fraction 0.0-1.0."""
return (
int(color_a[0] + (color_b[0] - color_a[0]) * frac),
int(color_a[1] + (color_b[1] - color_a[1]) * frac),
int(color_a[2] + (color_b[2] - color_a[2]) * frac),
)
def pack_rgb(red, green, blue):
"""Pack r, g, b bytes into a 24-bit integer."""
return (red << 16) | (green << 8) | blue
def get_period(hour_24):
"""Return period key for a 24-hour value."""
if MORNING_HOUR <= hour_24 < DAY_HOUR:
return "morning"
if DAY_HOUR <= hour_24 < EVENING_HOUR:
return "day"
if EVENING_HOUR <= hour_24 < NIGHT_HOUR:
return "evening"
return "night"
def build_gradient(top, mid, bot):
"""Return list of 32 packed RGB values for a 3-stop gradient."""
colors = []
half = HEIGHT // 2
for row in range(HEIGHT):
if row < half:
frac = row / max(half - 1, 1)
rgb = lerp_color(top, mid, frac)
else:
frac = (row - half) / max(half - 1, 1)
rgb = lerp_color(mid, bot, frac)
colors.append(pack_rgb(*rgb))
return colors
def draw_line(bmp, x_0, y_0, x_1, y_1, idx):
"""Draw a 1-pixel wide line using Bresenham's algorithm."""
d_x = abs(x_1 - x_0)
d_y = -abs(y_1 - y_0)
step_x = 1 if x_0 < x_1 else -1
step_y = 1 if y_0 < y_1 else -1
err = d_x + d_y
while True:
if 0 <= x_0 < WIDTH and 0 <= y_0 < HEIGHT:
bmp[x_0, y_0] = idx
if x_0 == x_1 and y_0 == y_1:
break
err2 = 2 * err
if err2 >= d_y:
err += d_y
x_0 += step_x
if err2 <= d_x:
err += d_x
y_0 += step_y
def draw_thick_line(bmp, x_0, y_0, x_1, y_1, idx):
"""Draw a ~2 px wide line (three parallel lines)."""
draw_line(bmp, x_0, y_0, x_1, y_1, idx)
draw_line(bmp, x_0 + 1, y_0, x_1 + 1, y_1, idx)
draw_line(bmp, x_0, y_0 + 1, x_1, y_1 + 1, idx)
def fill_dot(bmp, c_x, c_y, radius, idx):
"""Fill a small circular area."""
for d_y in range(-radius, radius + 1):
for d_x in range(-radius, radius + 1):
if d_x * d_x + d_y * d_y <= radius * radius:
p_x = c_x + d_x
p_y = c_y + d_y
if 0 <= p_x < WIDTH and 0 <= p_y < HEIGHT:
bmp[p_x, p_y] = idx
# ================================================================== #
# DISPLAY SETUP #
# ================================================================== #
displayio.release_displays()
# -- Background palette + bitmap ------------------------------------
BG_COLORS = 128
BG_TIERS = 4
BG_MULTS = (0.75, 0.90, 1.10, 1.35)
bg_palette = displayio.Palette(BG_COLORS)
bg_bitmap = displayio.Bitmap(WIDTH, HEIGHT, BG_COLORS)
# Pre-compute radial glow map
GLOW_RADIUS = 16.0
glow_map = [] # pylint: disable=invalid-name
for _gy in range(HEIGHT):
glow_row = []
for _gx in range(WIDTH):
dist = math.sqrt(
(_gx - CENTER_X) ** 2 + (_gy - CENTER_Y) ** 2
)
g_frac = max(0.0, 1.0 - dist / GLOW_RADIUS)
if g_frac > 0.70:
t_ofs = 96 # pylint: disable=invalid-name
elif g_frac > 0.45:
t_ofs = 64 # pylint: disable=invalid-name
elif g_frac > 0.20:
t_ofs = 32
else:
t_ofs = 0
glow_row.append(t_ofs)
glow_map.append(glow_row)
# -- Foreground palette + bitmap ------------------------------------
FG_COLORS = 16
fg_palette = displayio.Palette(FG_COLORS)
fg_palette.make_transparent(IDX_CLEAR)
fg_bitmap = displayio.Bitmap(WIDTH, HEIGHT, FG_COLORS)
# -- Matrix init ----------------------------------------------------
rgb_matrix = rgbmatrix.RGBMatrix(
width=WIDTH,
height=HEIGHT,
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,
],
clock_pin=board.MTX_CLK,
latch_pin=board.MTX_LAT,
output_enable_pin=board.MTX_OE,
doublebuffer=True,
)
display = framebufferio.FramebufferDisplay(
rgb_matrix, auto_refresh=False
)
display.rotation = DEFAULT_ROTATION
group = displayio.Group()
group.append(displayio.TileGrid(bg_bitmap, pixel_shader=bg_palette))
group.append(displayio.TileGrid(fg_bitmap, pixel_shader=fg_palette))
display.root_group = group
# ================================================================== #
# SETUP SCREEN HELPERS #
# ================================================================== #
# Use bg_palette index 0 for black, index 1 for text colour
def setup_screen(lines, color=0x00C6FF):
"""Display centered text lines on a black background."""
bg_palette[0] = 0x000000
bg_palette[1] = color
bg_bitmap.fill(0)
fg_bitmap.fill(IDX_CLEAR)
total_h = len(lines) * 7
start_y = (HEIGHT - total_h) // 2
for i, txt in enumerate(lines):
draw_text_3x5(
bg_bitmap, txt, center_text_x(txt),
start_y + i * 7, 1
)
display.refresh()
def setup_time_display(hour, minute, is_editing_hr, blink_on):
"""Show time being set. Blink the field being edited."""
bg_palette[0] = 0x000000
bg_palette[1] = 0x00C6FF
bg_palette[2] = 0x666666
bg_bitmap.fill(0)
fg_bitmap.fill(IDX_CLEAR)
h_str = "{:02d}".format(hour)
m_str = "{:02d}".format(minute)
# "SET TIME" label at top
draw_text_3x5(
bg_bitmap, "SET", center_text_x("SET"), 2, 2
)
# Time display: HH:MM centered
time_str = "{}:{}".format(h_str, m_str)
t_x = center_text_x(time_str)
t_y = 14
if is_editing_hr:
h_pal = 1 if blink_on else 0
m_pal = 2
else:
h_pal = 2
m_pal = 1 if blink_on else 0
# Draw each part with its palette index
draw_text_3x5(bg_bitmap, h_str, t_x, t_y, h_pal)
draw_text_3x5(bg_bitmap, ":", t_x + 8, t_y, 2)
draw_text_3x5(bg_bitmap, m_str, t_x + 12, t_y, m_pal)
# "UP" / "DOWN" hints at bottom
draw_text_3x5(bg_bitmap, "UP", 1, 25, 2)
draw_text_3x5(bg_bitmap, "DOWN", 15, 25, 2)
display.refresh()
# ================================================================== #
# BUTTON SETUP #
# ================================================================== #
btn_up = digitalio.DigitalInOut(board.BUTTON_UP)
btn_up.direction = digitalio.Direction.INPUT
btn_up.pull = digitalio.Pull.UP
btn_down = digitalio.DigitalInOut(board.BUTTON_DOWN)
btn_down.direction = digitalio.Direction.INPUT
btn_down.pull = digitalio.Pull.UP
PERIODS = ("morning", "day", "evening", "night")
demo_index = -1 # pylint: disable=invalid-name
btn_was_pressed = False # pylint: disable=invalid-name
btn_dn_was_pressed = False # pylint: disable=invalid-name
wave_speed = WAVE_SPEED # pylint: disable=invalid-name
rot_index = ROTATIONS.index(DEFAULT_ROTATION) # pylint: disable=invalid-name
up_hold_start = 0.0 # pylint: disable=invalid-name
up_handled = False # pylint: disable=invalid-name
# ================================================================== #
# WIFI + NTP — OR MANUAL TIME SET #
# ================================================================== #
has_wifi = False # pylint: disable=invalid-name
last_sync = 0 # pylint: disable=invalid-name
active_period = None # pylint: disable=invalid-name
ntp = None # pylint: disable=invalid-name
pool = None # pylint: disable=invalid-name
ssid = os.getenv("CIRCUITPY_WIFI_SSID")
password = os.getenv("CIRCUITPY_WIFI_PASSWORD")
if ssid and password:
setup_screen(["WIFI", "..."])
try:
import socketpool # pylint: disable=import-outside-toplevel
import wifi # pylint: disable=import-outside-toplevel
import adafruit_ntp # pylint: disable=import-outside-toplevel
print("Connecting to WiFi...")
wifi.radio.connect(ssid, password)
print("WiFi OK - IP: {}".format(wifi.radio.ipv4_address))
pool = socketpool.SocketPool(wifi.radio)
# First sync with standard offset to get correct date
print("NTP sync (standard offset)...")
ntp = adafruit_ntp.NTP(
pool, tz_offset=TZ_STD_OFFSET, cache_seconds=3600
)
rtc.RTC().datetime = ntp.datetime
# Compute DST from the date we just got
tz_offset = compute_dst_offset(TZ_STD_OFFSET)
if tz_offset != TZ_STD_OFFSET:
print("DST active: offset {}".format(tz_offset))
ntp = adafruit_ntp.NTP(
pool, tz_offset=tz_offset, cache_seconds=3600
)
rtc.RTC().datetime = ntp.datetime
else:
print("Standard time: offset {}".format(tz_offset))
last_sync = time.monotonic()
has_wifi = True # pylint: disable=invalid-name
print("NTP sync OK: {}".format(time.localtime()))
except Exception as exc: # pylint: disable=broad-except
print("WiFi failed: {}".format(exc))
setup_screen(["WIFI", "ERROR"])
time.sleep(2)
if not has_wifi:
# ---- No WiFi: manual time set mode ----
if not ssid:
setup_screen(["NO WIFI", "IN TOML"])
print("No WiFi creds in settings.toml")
else:
print("Falling back to manual time set")
time.sleep(2)
set_hour = 12 # pylint: disable=invalid-name
set_min = 0 # pylint: disable=invalid-name
editing_hr = True # pylint: disable=invalid-name
blink_timer = time.monotonic() # pylint: disable=invalid-name
blink_state = True # pylint: disable=invalid-name
up_prev = True # pylint: disable=invalid-name
dn_prev = True # pylint: disable=invalid-name
hold_start = 0.0 # pylint: disable=invalid-name
HOLD_TIME = 1.5 # seconds to hold for confirm
print("Manual time set: UP=increment, DOWN=toggle field")
print(" Long press either button to confirm")
while True:
# Blink toggle every 0.4s
if time.monotonic() - blink_timer > 0.4:
blink_state = not blink_state # pylint: disable=invalid-name
blink_timer = time.monotonic() # pylint: disable=invalid-name
setup_time_display(
set_hour, set_min, editing_hr, blink_state
)
up_now = not btn_up.value
dn_now = not btn_down.value
# -- Long press detection (either button) --
if up_now or dn_now:
if hold_start == 0.0:
hold_start = time.monotonic() # pylint: disable=invalid-name
elif time.monotonic() - hold_start >= HOLD_TIME:
# Long press: accept time
print("Time confirmed")
cur_t = time.localtime()
rtc.RTC().datetime = time.struct_time((
cur_t.tm_year, cur_t.tm_mon,
cur_t.tm_mday, set_hour, set_min,
0, cur_t.tm_wday, cur_t.tm_yday, -1
))
print("RTC set to {:02d}:{:02d}".format(
set_hour, set_min
))
break
else:
hold_start = 0.0 # pylint: disable=invalid-name
# -- Short press: UP increments, DOWN toggles field --
if up_now and not up_prev:
if editing_hr:
set_hour = (set_hour + 1) % 24 # pylint: disable=invalid-name
else:
set_min = (set_min + 1) % 60 # pylint: disable=invalid-name
if dn_now and not dn_prev:
editing_hr = not editing_hr # pylint: disable=invalid-name
if editing_hr:
print("Editing: hours")
else:
print("Editing: minutes")
up_prev = up_now # pylint: disable=invalid-name
dn_prev = dn_now # pylint: disable=invalid-name
time.sleep(0.05)
def sync_time():
"""Set the onboard RTC from the NTP server (WiFi only)."""
global last_sync, ntp # pylint: disable=global-statement,invalid-name
if not has_wifi:
return
try:
# Recompute DST in case it changed since boot
tz_ofs = compute_dst_offset(TZ_STD_OFFSET)
ntp = adafruit_ntp.NTP(
pool, tz_offset=tz_ofs, cache_seconds=3600
)
rtc.RTC().datetime = ntp.datetime
last_sync = time.monotonic()
print("NTP sync OK (offset {}): {}".format(
tz_ofs, time.localtime()
))
except Exception as exc: # pylint: disable=broad-except
print("NTP sync failed: {}".format(exc))
# ================================================================== #
# PALETTE / GRADIENT BUILDERS #
# ================================================================== #
def palette_for_period(period):
"""Return (gradient_tuple, hand_hex, marker_hex)."""
table = {
"morning": (GRAD_MORNING, HAND_MORNING, MARKER_MORNING),
"day": (GRAD_DAY, HAND_DAY, MARKER_DAY),
"evening": (GRAD_EVENING, HAND_EVENING, MARKER_EVENING),
"night": (GRAD_NIGHT, HAND_NIGHT, MARKER_NIGHT),
}
return table[period]
def _fill_tiered_palette(gradient):
"""Fill background palette with dim-to-bright tiers."""
for row in range(HEIGHT):
base = gradient[row]
r_b = (base >> 16) & 0xFF
g_b = (base >> 8) & 0xFF
b_b = base & 0xFF
for t_idx in range(BG_TIERS):
mult = BG_MULTS[t_idx]
t_r = min(255, int(r_b * mult))
t_g = min(255, int(g_b * mult))
t_b = min(255, int(b_b * mult))
bg_palette[t_idx * 32 + row] = pack_rgb(
t_r, t_g, t_b
)
def _glow_color(color_hex):
"""Return colour at ~30% brightness for hand glow halo."""
return pack_rgb(
((color_hex >> 16) & 0xFF) // 3,
((color_hex >> 8) & 0xFF) // 3,
(color_hex & 0xFF) // 3,
)
def apply_background(period):
"""Rebuild palette colours for a new period (called once)."""
grad_stops, hand_hex, marker_hex = palette_for_period(period)
_fill_tiered_palette(build_gradient(*grad_stops))
fg_palette[IDX_HAND] = hand_hex
fg_palette[IDX_MARKER] = marker_hex
fg_palette[IDX_CARDINAL] = marker_hex
fg_palette[IDX_CENTER] = hand_hex
fg_palette[IDX_GLOW] = _glow_color(hand_hex)
for s_i, _ in enumerate(STARS):
fg_palette[IDX_STAR_BASE + s_i] = 0xFFDD00
# Wave animation parameters
WAVE_AMP = 2.5 # max row displacement in pixels
WAVE_LEN_X = 0.22 # horizontal frequency (across columns)
WAVE_LEN_Y = 0.15 # vertical frequency (subtle secondary)
def animate_background(mono_now):
"""Update background bitmap with wave + radial glow."""
spd = wave_speed
for row_y in range(HEIGHT):
g_row = glow_map[row_y]
for col_x in range(WIDTH):
wave1 = math.sin(
col_x * WAVE_LEN_X + mono_now * spd
)
wave2 = math.sin(
row_y * WAVE_LEN_Y - mono_now * spd * 0.7
)
offset = (wave1 + wave2 * 0.5) * WAVE_AMP
idx = int(row_y + offset)
if idx < 0:
idx = 0
elif idx >= HEIGHT:
idx = HEIGHT - 1
bg_bitmap[col_x, row_y] = idx + g_row[col_x]
# ================================================================== #
# FRAME RENDERER #
# ================================================================== #
def _draw_markers():
"""Draw 12 hour markers as plus shapes."""
for h_mark in range(12):
angle = h_mark * math.pi / 6.0
m_x = int(CENTER_X + CLOCK_RADIUS * math.sin(angle))
m_y = int(CENTER_Y - CLOCK_RADIUS * math.cos(angle))
p_idx = IDX_CARDINAL if h_mark % 3 == 0 else IDX_MARKER
if 0 <= m_x < WIDTH and 0 <= m_y < HEIGHT:
fg_bitmap[m_x, m_y] = p_idx
for d_xy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
p_x = m_x + d_xy[0]
p_y = m_y + d_xy[1]
if 0 <= p_x < WIDTH and 0 <= p_y < HEIGHT:
fg_bitmap[p_x, p_y] = p_idx
def _draw_stars(mono_now):
"""Draw twinkling yellow stars (nighttime only)."""
for s_idx, star in enumerate(STARS):
s_x, s_y, phase = star
brightness = 0.25 + 0.75 * max(
0.0, math.sin(mono_now * 0.4 + phase)
)
fg_palette[IDX_STAR_BASE + s_idx] = pack_rgb(
int(0xFF * brightness), int(0xDD * brightness), 0
)
if 0 <= s_x < WIDTH and 0 <= s_y < HEIGHT:
fg_bitmap[s_x, s_y] = IDX_STAR_BASE + s_idx
def _draw_hands(hours, minutes, seconds):
"""Draw glow halos then hands on top, plus center dot."""
m_angle = (minutes * math.pi / 30.0
+ seconds * math.pi / 1800.0)
m_end_x = int(CENTER_X + MINUTE_HAND_LEN * math.sin(m_angle))
m_end_y = int(CENTER_Y - MINUTE_HAND_LEN * math.cos(m_angle))
h_angle = ((hours % 12) * math.pi / 6.0
+ minutes * math.pi / 360.0)
h_end_x = int(CENTER_X + HOUR_HAND_LEN * math.sin(h_angle))
h_end_y = int(CENTER_Y - HOUR_HAND_LEN * math.cos(h_angle))
# Glow halos (drawn first, hands paint over)
draw_thick_line(
fg_bitmap, CENTER_X, CENTER_Y, m_end_x, m_end_y, IDX_GLOW
)
draw_thick_line(
fg_bitmap, CENTER_X, CENTER_Y, h_end_x, h_end_y, IDX_GLOW
)
draw_line(
fg_bitmap, CENTER_X - 1, CENTER_Y - 1,
h_end_x - 1, h_end_y - 1, IDX_GLOW
)
draw_line(
fg_bitmap, CENTER_X + 1, CENTER_Y + 1,
h_end_x + 1, h_end_y + 1, IDX_GLOW
)
# Minute hand (single pixel, over glow)
draw_line(
fg_bitmap, CENTER_X, CENTER_Y, m_end_x, m_end_y, IDX_HAND
)
# Hour hand (thicker, over glow)
draw_thick_line(
fg_bitmap, CENTER_X, CENTER_Y, h_end_x, h_end_y, IDX_HAND
)
# Center pivot dot
fill_dot(fg_bitmap, CENTER_X, CENTER_Y, 1, IDX_CENTER)
def draw_clock(hours, minutes, seconds, mono_now,
period_override=None):
"""Render one frame of the analog clock onto the foreground."""
global active_period # pylint: disable=global-statement,invalid-name
if period_override is not None:
period = period_override
else:
period = get_period(hours)
if period != active_period:
apply_background(period)
active_period = period
animate_background(mono_now)
fg_bitmap.fill(IDX_CLEAR)
_draw_markers()
if period == "night":
_draw_stars(mono_now)
_draw_hands(hours, minutes, seconds)
display.refresh()
# ================================================================== #
# MAIN LOOP #
# ================================================================== #
if has_wifi:
print("UP = wave speed (hold=rotate) | DOWN = backgrounds")
else:
print("Offline: UP = speed (hold=rotate) | DOWN = bg")
now = time.localtime()
draw_clock(now.tm_hour, now.tm_min, now.tm_sec, time.monotonic())
while True:
# ---- UP button: short=wave speed, long=rotate display ----
btn_pressed = not btn_up.value
if btn_pressed:
if not btn_was_pressed:
# Button just pressed — start hold timer
up_hold_start = time.monotonic() # pylint: disable=invalid-name
up_handled = False # pylint: disable=invalid-name
elif (not up_handled
and time.monotonic() - up_hold_start >= 1.5):
# Long press: cycle rotation
rot_index = (rot_index + 1) % 4 # pylint: disable=invalid-name
display.rotation = ROTATIONS[rot_index]
up_handled = True # pylint: disable=invalid-name
print("Rotation: {}".format(ROTATIONS[rot_index]))
elif btn_was_pressed and not up_handled:
# Button released before long press — short press
if wave_speed == WAVE_SPEED:
wave_speed = WAVE_SPEED_FAST # pylint: disable=invalid-name
print("Wave speed: fast")
else:
wave_speed = WAVE_SPEED # pylint: disable=invalid-name
print("Wave speed: calm")
btn_was_pressed = btn_pressed # pylint: disable=invalid-name
# ---- DOWN button: cycle backgrounds ----
btn_dn_pressed = not btn_down.value
if btn_dn_pressed and not btn_dn_was_pressed:
demo_index = demo_index + 1 # pylint: disable=invalid-name
if demo_index >= len(PERIODS):
demo_index = -1 # pylint: disable=invalid-name
print("Auto mode (time-based)")
else:
print("Demo: {}".format(PERIODS[demo_index]))
active_period = None # force background redraw
btn_dn_was_pressed = btn_dn_pressed # pylint: disable=invalid-name
# Build override (None = use real time)
override = PERIODS[demo_index] if demo_index >= 0 else None
now = time.localtime()
draw_clock(
now.tm_hour, now.tm_min, now.tm_sec,
time.monotonic(), period_override=override
)
if has_wifi and time.monotonic() - last_sync > SYNC_INTERVAL:
sync_time()
time.sleep(0.05)
Copy code.py to the root of your CIRCUITPY drive. Then, copy the required library files from the bundle's lib folder to the lib folder on your CIRCUITPY drive.
The only library required is adafruit_ntp.mpy. This is used for syncing time over WiFi via NTP.
How It Works
The code draws an analog clock face on the 32x32 RGB LED matrix using two display layers. A background layer handles the gradient and wave animation. A foreground layer with transparency handles the clock hands, hour markers, and stars. Both layers are composited together every frame and pushed to the display.
Clock Geometry
WIDTH = 32 HEIGHT = 32 CENTER_X = 16 CENTER_Y = 16 CLOCK_RADIUS = 14 MINUTE_HAND_LEN = 11 HOUR_HAND_LEN = 7
These values define the clock face layout on the 32x32 pixel grid. The center point is at pixel (16, 16). The hour markers sit at a radius of 14 pixels from center. The minute hand extends 11 pixels and the hour hand 7 pixels.
-
CENTER_X/CENTER_Y— Shift the clock face position if your panel has an offset -
CLOCK_RADIUS— How far the hour markers sit from center -
MINUTE_HAND_LEN/HOUR_HAND_LEN— Length of each hand in pixels. Keep the minute hand shorter than the radius to leave a gap between the hand tip and the markers
Time Periods
SYNC_INTERVAL = 3600 MORNING_HOUR = 6 DAY_HOUR = 12 EVENING_HOUR = 17 NIGHT_HOUR = 20
The background gradient changes automatically based on the current hour. Four time-of-day periods are defined using 24-hour boundaries. The NTP time re-syncs once per hour by default.
-
MORNING_HOUR— Hour when the morning palette begins (default 6 AM) -
DAY_HOUR— Hour when the daytime palette begins (default 12 PM) -
EVENING_HOUR— Hour when the evening palette begins (default 5 PM) -
NIGHT_HOUR— Hour when the nighttime palette begins (default 8 PM) -
SYNC_INTERVAL— How often to re-sync with NTP in seconds
Wave Animation
WAVE_SPEED = 0.9 WAVE_SPEED_FAST = 1.8 WAVE_AMP = 2.5 WAVE_LEN_X = 0.22 WAVE_LEN_Y = 0.15
The background gradient has a slow, calming wave animation that displaces the gradient colors vertically. Two overlapping sine waves create an organic, water-like ripple effect. A short press on the UP button toggles between the calm and fast speeds at runtime.
-
WAVE_SPEED— Default wave speed in radians per second (calm mode) -
WAVE_SPEED_FAST— Fast wave speed, toggled with UP button -
WAVE_AMP— Maximum row displacement in pixels. Higher values create more dramatic waves -
WAVE_LEN_X— Horizontal frequency of the wave. Lower values create wider, gentler curves -
WAVE_LEN_Y— Vertical frequency of the secondary wave. Adds organic irregularity
Color Palettes
GRAD_MORNING = (
(0x73, 0x61, 0xB5), (0xF2, 0xBC, 0x42), (0x5D, 0x6A, 0x2F)
)
GRAD_DAY = (
(0x31, 0x6B, 0xB4), (0xC4, 0xDC, 0xF4), (0xD7, 0xB9, 0x96)
)
GRAD_EVENING = (
(0x63, 0x9B, 0xAC), (0xB9, 0x65, 0x65), (0x7C, 0xA0, 0xD2)
)
GRAD_NIGHT = (
(0xCD, 0x41, 0x7E), (0x64, 0x29, 0x69), (0x2F, 0x6E, 0x99)
)
Each time period has a three-stop gradient that flows from top to bottom of the display. The colors are inspired by the Florida Arts License Plate. Each tuple contains three RGB color values: the top color, the middle color, and the bottom color.
- First tuple — Top of the display
- Second tuple — Middle of the display
- Third tuple — Bottom of the display
The gradient is built with 32 steps (one per row) and blends smoothly between the three stops.
Hand and Marker Colors
HAND_MORNING = 0x00C6FF HAND_DAY = 0xDD5500 HAND_EVENING = 0xEAFF00 HAND_NIGHT = 0xFFDD00 MARKER_MORNING = 0x00C6FF MARKER_DAY = 0xDD5500 MARKER_EVENING = 0xEAFF00 MARKER_NIGHT = 0xFFDD00
Each time period has its own hand and marker color, chosen for contrast against the background gradient. The hand color is also used for the center pivot dot and the glow halo around the hands. The glow is automatically computed at 30% of the hand brightness.
-
HAND_*— Color of the hour and minute hands for each period. Use hex RGB values like0xFF0000for red -
MARKER_*— Color of the 12 hour markers (the plus-shaped dots around the dial)
Stars
STARS = [
(3, 3, 0.0), (27, 5, 0.8), (2, 24, 1.5),
(28, 27, 2.3), (11, 2, 3.0), (22, 29, 3.8),
(8, 10, 4.2), (24, 12, 5.0), (9, 22, 5.7),
(23, 21, 0.4),
]
Stars appear only during the nighttime period. Each entry is an x position, y position, and a phase offset that controls the twinkle timing. The stars fade in and out using a slow sine wave, creating a gentle twinkling effect in yellow.
- First value — X pixel position (0 to 31)
- Second value — Y pixel position (0 to 31)
- Third value — Phase offset in radians. Different values make each star twinkle at a different time
You can add, remove, or reposition stars. Avoid placing them within the clock radius where the hands sweep.
Radial Glow
BG_MULTS = (0.75, 0.90, 1.10, 1.35) GLOW_RADIUS = 16.0
The background has a radial vignette effect that makes the center of the display brighter and the edges dimmer. This is achieved by using a 128-entry palette with four brightness tiers. A pre-computed glow map assigns each pixel to a tier based on its distance from center.
-
BG_MULTS— Brightness multipliers for the four tiers, from edge to center. The default goes from 75% of the base gradient color at the edges to 135% at the center. Each channel is clamped to 255 so no pixel exceeds the maximum LED output -
GLOW_RADIUS— How far the center glow extends in pixels. At 16.0 it covers nearly the entire 32x32 panel
TZ_STD_OFFSET = int(os.getenv("TZ_STD_OFFSET") or "-5")
DST_AUTO = (os.getenv("DST_AUTO") or "true").lower() == "true"
US Daylight Saving Time is computed automatically from the date. The code calculates the second Sunday in March and the first Sunday in November to determine if DST is currently active. If so, it adds one hour to the standard UTC offset. This is recomputed on every hourly NTP re-sync, so if the clock is running across a DST transition, it will pick up the change within the hour.
These values are read from your settings.toml file. See the Create Your settings.toml File page for details.
Display Setup
rgb_matrix = rgbmatrix.RGBMatrix(
width=WIDTH,
height=HEIGHT,
bit_depth=2,
...
doublebuffer=True,
)
The matrix is initialized at bit_depth=2 with double buffering. The low bit depth provides a fast scan cycle that eliminates visible flicker on camera and in person. Double buffering prevents tearing during frame updates. The display uses manual refresh so the entire frame is drawn before being pushed to the panel.
WiFi and NTP
On boot, the code checks for WiFi credentials in settings.toml. If found, it connects to WiFi, syncs the time via Network Time Protocol (NTP), and computes the DST offset from the date. If WiFi is not available or the connection fails, the display shows a message and enters a manual time-set mode.
In the manual time-set mode, the UP button increments the blinking value, the DOWN button toggles between hours and minutes, and a long press on either button confirms the time.
Button Controls
During normal clock operation, the two buttons on the MatrixPortal S3 control different features.
The UP button has two actions. A short press toggles the wave animation speed between calm and fast. A long press (1.5 seconds) cycles the display rotation through 0, 90, 180, and 270 degrees.
The DOWN button cycles through the four background palettes for demoing or tweaking colors. After cycling through morning, day, evening, and night, one more press returns to automatic time-based mode.
Drawing the Clock Face
Each frame, the code performs these steps in order.
- Update the background bitmap with the wave animation and radial glow
- Clear the foreground bitmap to transparent
- Draw the 12 hour markers as plus-shaped crosses
- Draw twinkling stars if in nighttime mode
- Draw the glow halos for both hands
- Draw the minute hand (single pixel width) on top of the glow
- Draw the hour hand (three pixel width) on top of the glow
- Draw the center pivot dot
- Push the completed frame to the display
The frame loop runs at approximately 20 frames per second with a 0.05 second sleep between frames.
Page last edited March 17, 2026
Text editor powered by tinymce.
3D Printing
3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
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
- Tree Supports
- 60 C heated bed
Design Source Files
The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported in different formats like STEP, STL and more.
Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo
Page last edited March 17, 2026
Text editor powered by tinymce.
Assembly
Shorten the included power cable so it will reach from the Matrix Portal to the power input once connected without too much excess wire, as shown below a few steps down.
You can also remove one of the plastic header ends and wires from the branch point, since we only need one set of connections.
The Matrix Portal fastens on top of a printed plate that orients the board so the buttons can be pressed from the side of the display.
The power cable plugs into the VCC and GND header pins, where the clip can grip to the nubs next to the pins.
Use the included machine screws to fasten to the red wire to the 5V terminal and the black wire to the GND terminal on the Matrix Portal.
The printed diffuser has nubs on two of the sides that align to the two slots on the side of the display.
Press fit the diffuser one side at a time. You can use the two extruded wall mounts to help remove the diffuser if needed.
Now we can use a USB-C cable to power it up and mount it on a wall or set it on a desk.
Page last edited March 17, 2026
Text editor powered by tinymce.
Usage
Once the clock is assembled and powered on, it will either sync time automatically over WiFi or enter the manual time-set mode depending on your settings.toml configuration.
Boot Sequence
On power up, the display shows WIFI ... while the MatrixPortal S3 connects to your network and syncs the time via NTP. The entire boot process takes a few seconds. Once the time is set, the clock face appears immediately.
If no WiFi credentials are found in settings.toml, the display shows NO WIFI IN TOML for two seconds and then enters the manual time-set screen. If credentials are present but the connection fails, the display shows WIFI ERROR before falling back to manual mode.
Manual Time Set
The manual time-set screen displays SET at the top with HH:MM in the center. The active field blinks to indicate which value you are editing. Hours are edited first.
- UP button (short press) — Increments the blinking value. Hours wrap from 23 back to 0. Minutes wrap from 59 back to 0.
- DOWN button (short press) — Toggles between editing hours and minutes. You can switch back and forth as many times as needed to get both values right.
- Long press either button (1.5 seconds) — Confirms the displayed time and starts the clock.
The bottom of the screen shows UP and DOWN as button hints.
Time-of-Day Backgrounds
The background gradient transitions automatically based on the current hour. Each period has its own color palette with matching hand and marker colors.
- Morning (6 AM to 12 PM) — Purple to gold to olive green gradient with cyan hands
- Daytime (12 PM to 5 PM) — Blue to pale blue to sandy tan gradient with rich orange hands
- Evening (5 PM to 8 PM) — Teal to salmon to periwinkle gradient with yellow-green hands
- Night (8 PM to 6 AM) — Pink to purple to blue gradient with yellow hands and twinkling yellow stars
The transition happens instantly when the clock crosses a period boundary. There is no gradual blend between periods.
Wave Animation
The background gradient has a continuous wave animation that gently displaces the colors up and down across the display. Two overlapping sine waves at different speeds and angles create an organic, water-like ripple. Behind the diffuser, this creates a soft, calming motion.
The default wave speed is set to a calm pace. A short press on the UP button toggles to a faster, more energetic wave. Press again to return to calm.
Radial Glow
The center of the display is brighter than the edges. This radial vignette effect is built into the background gradient with four brightness tiers that fade from 135% of the base gradient color at the center down to 75% at the edges. No pixel ever exceeds the maximum LED brightness — the percentages are relative to the gradient color, not the hardware limit. Behind the diffuser, the tiers blend into a smooth glow.
Clock Hands
The minute hand is drawn as a single pixel line. The hour hand is drawn as a three pixel wide line to make it easy to tell the two apart. Both hands have a soft glow halo that is drawn behind them at 30% of the hand color. Behind the diffuser, the glow creates a smooth brightness ramp from the background into the hand color.
A small dot at the center marks the pivot point where the hands meet.
Hour Markers
All twelve hour positions are marked with plus-shaped crosses. Each cross is five pixels: one in the center and one extending in each cardinal direction. The markers use the same color as the clock hands for the current time period.
Nighttime Stars
During the nighttime period, ten yellow stars appear scattered across the clock face. Each star twinkles independently using a slow sine wave that fades the brightness up and down. The stars are positioned in the corners and inner areas of the dial, away from the hand sweep paths.
Display Rotation
The display can be rotated to match your mounting orientation. The default rotation is 90 degrees, which positions the USB cable pointing straight down for wall mounting.
A long press on the UP button (hold for 1.5 seconds) cycles through the four rotations at runtime. The rotation applies instantly. The serial console prints the current angle each time you cycle.
- 0 degrees — USB cable points left
- 90 degrees — USB cable points down
- 180 degrees — USB cable points right
- 270 degrees — USB cable points up
You can also set the default rotation in settings.toml with the DISPLAY_ROTATION value so it boots into your preferred orientation.
Background Demo Mode
A short press on the DOWN button cycles through the four background palettes regardless of the current time. This is useful for demoing all four looks or for tweaking colors in a well-lit environment.
The cycle order is morning, daytime, evening, nighttime. One more press after nighttime returns to automatic time-based mode. The serial console prints the current mode each time you cycle.
Daylight Saving Time
The clock automatically adjusts for US Daylight Saving Time. On boot, it syncs the time with the standard UTC offset, reads the date, and checks whether the current date falls between the second Sunday in March and the first Sunday in November. If so, it adds one hour to the offset and re-syncs.
The DST check also runs on every hourly NTP re-sync. If the clock is running continuously across a DST transition, it will pick up the change within one hour.
For locations outside the US or that do not observe DST, set DST_AUTO to "false" in settings.toml and use the correct fixed offset in TZ_STD_OFFSET.
Button Quick Reference
During normal clock operation:
- UP short press — Toggle wave speed (calm / fast)
- UP long press (1.5s) — Cycle display rotation
- DOWN short press — Cycle background palettes (morning, day, evening, night, auto)
During manual time set:
- UP short press — Increment the blinking value
- DOWN short press — Toggle between hours and minutes
- Long press either button (1.5s) — Confirm time and start clock
Page last edited March 17, 2026
Text editor powered by tinymce.