Code the Dispenser
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: 2026 Liz Clark for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
Frankenstein Candy Dispenser with LED Glasses
Original LED Glasses eye code by Phil B.
Button press triggers audio playback and servo to dispense candy
"""
import os
import time
import math
import random
from supervisor import reload
import board
import pwmio
from digitalio import DigitalInOut, Direction
from adafruit_motor import servo
import audiocore
import audiobusio
import audiomixer
import keypad
import adafruit_is31fl3741
from adafruit_is31fl3741.adafruit_ledglasses import LED_Glasses
import neopixel
# CONFIGURABLES ------------------------
eye_color = (0, 255, 0) # color of pupils
x_eye_color = (255, 0, 0) # color of X eyes
ring_open_color = (128, 0, 128) # color of LED rings when eyes open
ring_blink_color = (0, 0, 0) # color of LED ring "eyelid" when blinking
radius = 3.4 # Size of pupil (3X because of downsampling later)
candy_portion = 1 # servo run time in seconds (small 0.6)
servo_throttle = -1.0 # direction/speed of the screw while dispensing
volume = 1.0 # playback level for the sound effects, 0.0 to 1.0
idle_pixel_color = (255, 255, 255) # NeoPixel color when sitting idle
dispense_pixel_color = (255, 255, 255) # Color it pulses while dispensing
pulse_rate = 3.0 # NeoPixel pulses per second while dispensing
# 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
# servo control
pwm = pwmio.PWMOut(board.EXTERNAL_SERVO, duty_cycle=2 ** 15, frequency=50)
cont_servo = servo.ContinuousServo(pwm)
cont_servo.throttle = 0
# external neopixels
num_pixels = 1
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXELS, num_pixels)
pixels.brightness = 1
pixels.fill(idle_pixel_color)
# external button
keys = keypad.Keys((board.EXTERNAL_BUTTON,), value_when_pressed=False, pull=True)
# i2s playback -------------------------
# Collect every .wav in /sounds so one can be picked at random per press
wavs = []
for filename in os.listdir("/sounds"):
if filename.lower().endswith(".wav") and not filename.startswith("."):
wavs.append("/sounds/" + filename)
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,
buffer_size=8192)
audio.play(mixer)
mixer.voice[0].level = 0.0
current_file = None
def play_random_sound():
"""Pick a random wav from /sounds and start it playing."""
global current_file # pylint: disable=global-statement
if not wavs:
return
mixer.voice[0].stop()
if current_file is not None:
current_file.close()
current_file = open(random.choice(wavs), "rb")
mixer.voice[0].level = volume
mixer.voice[0].play(audiocore.WaveFile(current_file), loop=False)
i2c = board.STEMMA_I2C()
# Initialize the IS31 LED driver, buffered for smoother animation
glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER)
glasses.show() # Clear any residue on startup
glasses.global_current = 100 # Just middlin' bright, please
gamma = 2.6 # For color adjustment. Leave as-is.
# CLASSES & FUNCTIONS ------------------
# The X drawn over each eye while candy is dispensing
X_POINTS = (
(0, 0), (4, 0),
(1, 1), (3, 1),
(2, 2),
(1, 3), (3, 3),
(0, 4), (4, 4),
)
class Eye:
"""Holds per-eye positional data; each covers a different area of the
overall LED matrix."""
def __init__(self, left, xoff, xshift=0):
self.left = left # Leftmost column on LED matrix
self.x_offset = xoff # Horizontal offset (3X space) to fixate
self.x_shift = xshift # Horizontal nudge (whole px) for the X eye
def draw_x(self, color):
"""Clear this eye's section of the matrix and draw an X across it,
in place of the usual pupil. 'color' is a packed 24-bit RGB int."""
for y in range(5):
for x in range(6):
glasses.pixel(self.left + x, y, 0)
for point in X_POINTS:
glasses.pixel(self.left + point[0] + self.x_shift, point[1], color)
def smooth(self, data, rect):
"""Scale bitmap (in 'data') to LED array, with smooth 1:3
downsampling. 'rect' is a 4-tuple rect of which pixels get
filtered (anything outside is cleared to 0), saves a few cycles."""
# Quantize bounds rect from 3X space to LED matrix space.
rect = (
rect[0] // 3, # Left
rect[1] // 3, # Top
(rect[2] + 2) // 3, # Right
(rect[3] + 2) // 3, # Bottom
)
for y in range(rect[1]): # Erase rows above top
for x in range(6):
glasses.pixel(self.left + x, y, 0)
for y in range(rect[1], rect[3]): # Each row, top to bottom...
pixel_sum = bytearray(6) # Initialize row of pixel sums to 0
for y1 in range(3): # 3 rows of bitmap...
row = data[y * 3 + y1] # Bitmap data for current row
for x in range(rect[0], rect[2]): # Column, left to right
x3 = x * 3
# Accumulate 3 pixels of bitmap into pixel_sum
pixel_sum[x] += row[x3] + row[x3 + 1] + row[x3 + 2]
# 'pixel_sum' will now contain values from 0-9, indicating the
# number of set pixels in the corresponding section of the 3X
# bitmap. 'colormap' expands the sum to 24-bit RGB space.
for x in range(rect[0]): # Erase any columns to left
glasses.pixel(self.left + x, y, 0)
for x in range(rect[0], rect[2]): # Column, left to right
glasses.pixel(self.left + x, y, colormap[pixel_sum[x]])
for x in range(rect[2], 6): # Erase columns to right
glasses.pixel(self.left + x, y, 0)
for y in range(rect[3], 5): # Erase rows below bottom
for x in range(6):
glasses.pixel(self.left + x, y, 0)
# pylint: disable=too-many-locals
def rasterize(data, point1, point2, rect):
"""Rasterize an arbitrary ellipse into the 'data' bitmap (3X pixel
space), given foci point1 and point2 and with area determined by global
'radius' (when foci are same point; a circle). Foci and radius are all
floating point values, which adds to the buttery impression. 'rect' is
a 4-tuple rect of which pixels are likely affected. Data is assumed 0
before arriving here; no clearing is performed."""
dx = point2[0] - point1[0]
dy = point2[1] - point1[1]
d2 = dx * dx + dy * dy # Dist between foci, squared
if d2 <= 0:
# Foci are in same spot - it's a circle
perimeter = 2 * radius
d = 0
else:
# Foci are separated - it's an ellipse.
d = d2 ** 0.5 # Distance between foci
c = d * 0.5 # Center-to-foci distance
# This is an utterly brute-force way of ellipse-filling based on
# the "two nails and a string" metaphor...we have the foci points
# and just need the string length (triangle perimeter) to yield
# an ellipse with area equal to a circle of 'radius'.
# c^2 = a^2 - b^2 <- ellipse formula
# a = r^2 / b <- substitute
# c^2 = (r^2 / b)^2 - b^2
# b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2) <- solve for b
b2 = ((c ** 2) + (((c ** 4) + 4 * (radius ** 4)) ** 0.5)) * 0.5
# By my math, perimeter SHOULD be...
# perimeter = d + 2 * ((b2 + (c ** 2)) ** 0.5)
# ...but for whatever reason, working approach here is really...
perimeter = d + 2 * (b2 ** 0.5)
# Like I'm sure there's a way to rasterize this by spans rather than
# all these square roots on every pixel, but for now...
for y in range(rect[1], rect[3]): # For each row...
y5 = y + 0.5 # Pixel center
dy1 = y5 - point1[1] # Y distance from pixel to first point
dy2 = y5 - point2[1] # " to second
dy1 *= dy1 # Y1^2
dy2 *= dy2 # Y2^2
for x in range(rect[0], rect[2]): # For each column...
x5 = x + 0.5 # Pixel center
dx1 = x5 - point1[0] # X distance from pixel to first point
dx2 = x5 - point2[0] # " to second
d1 = (dx1 * dx1 + dy1) ** 0.5 # 2D distance to first point
d2 = (dx2 * dx2 + dy2) ** 0.5 # " to second
if (d1 + d2 + d) <= perimeter:
data[y][x] = 1 # Point is inside ellipse
def gammify(color):
"""Given an (R,G,B) color tuple, apply gamma correction and return
a packed 24-bit RGB integer."""
rgb = [int(((color[x] / 255) ** gamma) * 255 + 0.5) for x in range(3)]
return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
def interp(color1, color2, blend):
"""Given two (R,G,B) color tuples and a blend ratio (0.0 to 1.0),
interpolate between the two colors and return a gamma-corrected
in-between color as a packed 24-bit RGB integer. No bounds clamping
is performed on blend value, be nice."""
inv = 1.0 - blend # Weighting of second color
return gammify([color1[x] * blend + color2[x] * inv for x in range(3)])
# INITIALIZE TABLES & OTHER GLOBALS ----
# This table is for mapping 3x3 averaged bitmap values (0-9) to
# RGB colors. Avoids a lot of shift-and-or on every pixel.
colormap = []
for n in range(10):
colormap.append(gammify([n / 9 * eye_color[x] for x in range(3)]))
# Pre-compute the Y position of 1/2 of the LEDs in a ring, relative
# to the 3X bitmap resolution, so ring & matrix animation can be aligned.
y_pos = []
for n in range(13):
angle = n / 24 * math.pi * 2
y_pos.append(10 - math.cos(angle) * 12)
# Pre-compute color of LED ring in fully open (unblinking) state
ring_open_color_packed = gammify(ring_open_color)
# Pre-compute the packed color of the X eyes
x_eye_color_packed = gammify(x_eye_color)
# A single pre-computed scanline of "eyelid edge during blink" can be
# stuffed into the 3X raster as needed, avoids setting pixels manually.
eyelid = (
b"\x01\x01\x00\x01\x01\x00\x01\x01\x00" b"\x01\x01\x00\x01\x01\x00\x01\x01\x00"
) # 2/3 of pixels set
# Initialize eye position and move/blink animation timekeeping
cur_pos = next_pos = (9, 7.5) # Current, next eye position in 3X space
in_motion = False # True = eyes moving, False = eyes paused
blink_state = 0 # 0, 1, 2 = unblinking, closing, opening
move_start_time = move_duration = blink_start_time = blink_duration = 0
upper, lower = -4, 23 # Eyelid positions (3X space), start fully open
# Candy dispensing state. A press runs through two phases back to back:
# the sound plays, then the screw turns. Nothing blocks; the main loop
# just checks where it's up to each pass and keeps animating throughout.
STATE_IDLE = 0 # Waiting for a press
STATE_SOUND = 1 # Sound effect playing
STATE_DISPENSE = 2 # Servo turning
state = STATE_IDLE
busy_start_time = 0 # Time of the button press
dispense_start_time = 0 # Time the servo started turning
# Two eye objects. The first starts at column 1 of the matrix with its
# pupil offset by +2 (in 3X space), second at column 11 with -2 offset.
# The offsets make the pupils fixate slightly (converge on a point), so
# the two pupils aren't always aligned the same on the pixel grid, which
# would be conspicuously pixel-y.
eyes = [Eye(1, 2, 0), Eye(11, -2, 1)]
frames, start_time = 0, time.monotonic() # For frames/second calculation
while True:
now = time.monotonic()
event = keys.events.get()
if event:
# A key transition occurred.
# print(event)
if event.pressed and state == STATE_IDLE:
state = STATE_SOUND
busy_start_time = now
play_random_sound()
blink_state = 0
blink_duration = random.uniform(0.5, 4)
in_motion = False
move_duration = random.uniform(0.5, 1.5)
if state == STATE_SOUND:
if (now - busy_start_time) > 0.05 and not mixer.voice[0].playing:
state = STATE_DISPENSE # sound done, dispense candy
dispense_start_time = now
cont_servo.throttle = servo_throttle
elif state == STATE_DISPENSE:
if (now - dispense_start_time) >= candy_portion:
state = STATE_IDLE
cont_servo.throttle = 0
pixels.fill(idle_pixel_color)
try:
# The eye animation logic is a carry-over from like a billion
# prior eye projects, so this might be comment-light.
if state != STATE_IDLE:
phase = (now - busy_start_time) * pulse_rate
level = 0.5 - 0.5 * math.cos(phase * 2 * math.pi)
pixels.fill(tuple(int(c * level) for c in dispense_pixel_color))
# X eyes instead of pupils
for eye in eyes:
eye.draw_x(x_eye_color_packed)
glasses.left_ring.fill(ring_open_color_packed)
glasses.right_ring.fill(ring_open_color_packed)
blink_start_time = move_start_time = now
else:
# Blink logic
elapsed = now - blink_start_time # Time since start of blink event
if elapsed > blink_duration: # All done with event?
blink_start_time = now # A new one starts right now
elapsed = 0
blink_state += 1 # Cycle closing/opening/paused
if blink_state == 1: # Starting new blink...
blink_duration = random.uniform(0.06, 0.12)
elif blink_state == 2: # Switching closing to opening...
blink_duration *= 2 # Opens at half the speed
else: # Switching to pause in blink
blink_state = 0
blink_duration = random.uniform(0.5, 4)
if blink_state: # If currently in a blink...
ratio = elapsed / blink_duration # 0.0-1.0 as it closes
if blink_state == 2:
ratio = 1.0 - ratio # 1.0-0.0 as it opens
upper = ratio * 15 - 4 # Upper eyelid pos. in 3X space
lower = 23 - ratio * 8 # Lower eyelid pos. in 3X space
# Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
# ellipse. p1 moves from current to next position a little faster
# than p2, creating a "squash and stretch" effect (frame rate and
# resolution permitting). When motion is stopped, the two points
# are at the same position.
elapsed = now - move_start_time # Time since start of move event
if in_motion: # Currently moving?
if elapsed > move_duration: # If end of motion reached,
in_motion = False # Stop motion and
p1 = p2 = cur_pos = next_pos # Set to new position
move_duration = random.uniform(0.5, 1.5) # Wait this long
else: # Still moving
# Determine p1, p2 position in time
delta = (next_pos[0] - cur_pos[0], next_pos[1] - cur_pos[1])
ratio = elapsed / move_duration
if ratio < 0.6: # First 60% of move time
# p1 is in motion
# Easing function: 3*e^2-2*e^3 0.0 to 1.0
e = ratio / 0.6 # 0.0 to 1.0
e = 3 * e * e - 2 * e * e * e
p1 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
else: # Last 40% of move time
p1 = next_pos # p1 has reached end position
if ratio > 0.3: # Last 60% of move time
# p2 is in motion
e = (ratio - 0.3) / 0.7 # 0.0 to 1.0
e = 3 * e * e - 2 * e * e * e # Easing func.
p2 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
else: # First 40% of move time
p2 = cur_pos # p2 waits at start position
else: # Eye is stopped
p1 = p2 = cur_pos # Both foci at current eye position
if elapsed > move_duration: # Pause time expired?
in_motion = True # Start up new motion!
move_start_time = now
move_duration = random.uniform(0.15, 0.25)
angle = random.uniform(0, math.pi * 2)
dist = random.uniform(0, 7.5)
next_pos = (
9 + math.cos(angle) * dist,
7.5 + math.sin(angle) * dist * 0.8,
)
# Draw the raster part of each eye...
for eye in eyes:
# Allocate/clear the 3X bitmap buffer
bitmap = [bytearray(6 * 3) for _ in range(5 * 3)]
# Each eye's foci are offset slightly, to fixate toward center
p1a = (p1[0] + eye.x_offset, p1[1])
p2a = (p2[0] + eye.x_offset, p2[1])
# Compute bounding rectangle (in 3X space) of ellipse
# (min X, min Y, max X, max Y). Like the ellipse rasterizer,
# this isn't optimal, but will suffice.
bounds = (
max(int(min(p1a[0], p2a[0]) - radius), 0),
max(int(min(p1a[1], p2a[1]) - radius), 0, int(upper)),
min(int(max(p1a[0], p2a[0]) + radius + 1), 18),
min(int(max(p1a[1], p2a[1]) + radius + 1), 15, int(lower) + 1),
)
rasterize(bitmap, p1a, p2a, bounds) # Render ellipse into buffer
# If the eye is currently blinking, and if the top edge of the
# eyelid overlaps the bitmap, draw a scanline across the bitmap
# and update the bounds rect so the whole width of the bitmap
# is scaled.
if blink_state and upper >= 0:
bitmap[int(upper)] = eyelid
bounds = (0, int(upper), 18, bounds[3])
eye.smooth(bitmap, bounds) # 1:3 downsampling for eye
# Matrix and rings share a few pixels. To make the rings take
# precedence, they're drawn later. So blink state is revisited now...
if blink_state: # In mid-blink?
for i in range(13): # Half an LED ring, top-to-bottom...
a = min(max(y_pos[i] - upper + 1, 0), 3)
b = min(max(lower - y_pos[i] + 1, 0), 3)
ratio = a * b / 9 # Proximity of LED to eyelid edges
packed = interp(ring_open_color, ring_blink_color, ratio)
glasses.left_ring[i] = glasses.right_ring[i] = packed
if 0 < i < 12:
i = 24 - i # Mirror half-ring to other side
glasses.left_ring[i] = glasses.right_ring[i] = packed
else:
glasses.left_ring.fill(ring_open_color_packed)
glasses.right_ring.fill(ring_open_color_packed)
glasses.show() # Buffered mode MUST use show() to refresh matrix
except OSError: # See "try" notes above regarding rare I2C errors.
print("Restarting")
reload()
frames += 1
elapsed = time.monotonic() - start_time
#print(frames / elapsed)
Upload the Code, Sound Effects and Libraries to the RP2040 Prop-Maker Feather
After downloading the Project Bundle, plug your RP2040 Prop-Maker Feather into your 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 PropMaker Feather's CIRCUITPY drive.
- lib folder
- sounds folder
- code.py
Your RP2040 Prop-Maker Feather CIRCUITPY drive should look like this after copying the lib folder, sounds folder and code.py file:
How the CircuitPython Code Works
At the top of the code are a few user configuration settings. Their functionality is commented on each line, but you can change things like the sound effect volume, LED eye pupil size, candy portion, etc.
# CONFIGURABLES ------------------------ eye_color = (0, 255, 0) # color of pupils x_eye_color = (255, 0, 0) # color of X eyes ring_open_color = (128, 0, 128) # color of LED rings when eyes open ring_blink_color = (0, 0, 0) # color of LED ring "eyelid" when blinking radius = 3.4 # Size of pupil (3X because of downsampling later) candy_portion = 1 # servo run time in seconds (small 0.6) servo_throttle = -1.0 # direction/speed of the screw while dispensing volume = 1.0 # playback level for the sound effects, 0.0 to 1.0 idle_pixel_color = (255, 255, 255) # NeoPixel color when sitting idle dispense_pixel_color = (255, 255, 255) # Color it pulses while dispensing pulse_rate = 3.0 # NeoPixel pulses per second while dispensing
Peripherals
Next, all of the onboard peripherals are instantiated. This includes the servo, brain NeoPixel, button (setup as a keypad object), audio input and LED glasses 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
# servo control
pwm = pwmio.PWMOut(board.EXTERNAL_SERVO, duty_cycle=2 ** 15, frequency=50)
cont_servo = servo.ContinuousServo(pwm)
cont_servo.throttle = 0
# external neopixels
num_pixels = 1
pixels = neopixel.NeoPixel(board.EXTERNAL_NEOPIXELS, num_pixels)
pixels.brightness = 1
pixels.fill(idle_pixel_color)
# external button
keys = keypad.Keys((board.EXTERNAL_BUTTON,), value_when_pressed=False, pull=True)
# i2s playback -------------------------
# Collect every .wav in /sounds so one can be picked at random per press
wavs = []
for filename in os.listdir("/sounds"):
if filename.lower().endswith(".wav") and not filename.startswith("."):
wavs.append("/sounds/" + filename)
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,
buffer_size=8192)
audio.play(mixer)
mixer.voice[0].level = 0.0
current_file = None
i2c = board.STEMMA_I2C()
# Initialize the IS31 LED driver, buffered for smoother animation
glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER)
glasses.show() # Clear any residue on startup
glasses.global_current = 100 # Just middlin' bright, please
Blinky Eyes
The LED eyes code is from the original Blinky Eyes standalone code by Phil B. for the LED glasses and carrier board. That code was brought into this project to run in tandem with the candy dispenser code. The code utilizes its own class called Eye that takes care of the eye position tracking on the LED matrix.
The Loop
In the loop, keypad events are monitored for the button input. If the button is pressed, then the candy dispensing process kicks off. First, a random sound effect is played and the eyes show red X's. Then, when the sound effect finishes, the servo rotates and the candy is dispensed. After the servo stops, the eyes go back to the animation and the state is reset.
while True:
now = time.monotonic()
event = keys.events.get()
if event:
# A key transition occurred.
# print(event)
if event.pressed and state == STATE_IDLE:
state = STATE_SOUND
busy_start_time = now
play_random_sound()
blink_state = 0
blink_duration = random.uniform(0.5, 4)
in_motion = False
move_duration = random.uniform(0.5, 1.5)
if state == STATE_SOUND:
if (now - busy_start_time) > 0.05 and not mixer.voice[0].playing:
state = STATE_DISPENSE # sound done, dispense candy
dispense_start_time = now
cont_servo.throttle = servo_throttle
elif state == STATE_DISPENSE:
if (now - dispense_start_time) >= candy_portion:
state = STATE_IDLE
cont_servo.throttle = 0
pixels.fill(idle_pixel_color)
Otherwise, the original blinking eye animation runs on the LED glasses.
try:
# The eye animation logic is a carry-over from like a billion
# prior eye projects, so this might be comment-light.
if state != STATE_IDLE:
phase = (now - busy_start_time) * pulse_rate
level = 0.5 - 0.5 * math.cos(phase * 2 * math.pi)
pixels.fill(tuple(int(c * level) for c in dispense_pixel_color))
# X eyes instead of pupils
for eye in eyes:
eye.draw_x(x_eye_color_packed)
glasses.left_ring.fill(ring_open_color_packed)
glasses.right_ring.fill(ring_open_color_packed)
blink_start_time = move_start_time = now
else:
# Blink logic
elapsed = now - blink_start_time # Time since start of blink event
if elapsed > blink_duration: # All done with event?
blink_start_time = now # A new one starts right now
elapsed = 0
blink_state += 1 # Cycle closing/opening/paused
if blink_state == 1: # Starting new blink...
blink_duration = random.uniform(0.06, 0.12)
elif blink_state == 2: # Switching closing to opening...
blink_duration *= 2 # Opens at half the speed
else: # Switching to pause in blink
blink_state = 0
blink_duration = random.uniform(0.5, 4)
if blink_state: # If currently in a blink...
ratio = elapsed / blink_duration # 0.0-1.0 as it closes
if blink_state == 2:
ratio = 1.0 - ratio # 1.0-0.0 as it opens
upper = ratio * 15 - 4 # Upper eyelid pos. in 3X space
lower = 23 - ratio * 8 # Lower eyelid pos. in 3X space
# Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
# ellipse. p1 moves from current to next position a little faster
# than p2, creating a "squash and stretch" effect (frame rate and
# resolution permitting). When motion is stopped, the two points
# are at the same position.
elapsed = now - move_start_time # Time since start of move event
if in_motion: # Currently moving?
if elapsed > move_duration: # If end of motion reached,
in_motion = False # Stop motion and
p1 = p2 = cur_pos = next_pos # Set to new position
move_duration = random.uniform(0.5, 1.5) # Wait this long
else: # Still moving
# Determine p1, p2 position in time
delta = (next_pos[0] - cur_pos[0], next_pos[1] - cur_pos[1])
ratio = elapsed / move_duration
if ratio < 0.6: # First 60% of move time
# p1 is in motion
# Easing function: 3*e^2-2*e^3 0.0 to 1.0
e = ratio / 0.6 # 0.0 to 1.0
e = 3 * e * e - 2 * e * e * e
p1 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
else: # Last 40% of move time
p1 = next_pos # p1 has reached end position
if ratio > 0.3: # Last 60% of move time
# p2 is in motion
e = (ratio - 0.3) / 0.7 # 0.0 to 1.0
e = 3 * e * e - 2 * e * e * e # Easing func.
p2 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
else: # First 40% of move time
p2 = cur_pos # p2 waits at start position
else: # Eye is stopped
p1 = p2 = cur_pos # Both foci at current eye position
if elapsed > move_duration: # Pause time expired?
in_motion = True # Start up new motion!
move_start_time = now
move_duration = random.uniform(0.15, 0.25)
angle = random.uniform(0, math.pi * 2)
dist = random.uniform(0, 7.5)
next_pos = (
9 + math.cos(angle) * dist,
7.5 + math.sin(angle) * dist * 0.8,
)
# Draw the raster part of each eye...
for eye in eyes:
# Allocate/clear the 3X bitmap buffer
bitmap = [bytearray(6 * 3) for _ in range(5 * 3)]
# Each eye's foci are offset slightly, to fixate toward center
p1a = (p1[0] + eye.x_offset, p1[1])
p2a = (p2[0] + eye.x_offset, p2[1])
# Compute bounding rectangle (in 3X space) of ellipse
# (min X, min Y, max X, max Y). Like the ellipse rasterizer,
# this isn't optimal, but will suffice.
bounds = (
max(int(min(p1a[0], p2a[0]) - radius), 0),
max(int(min(p1a[1], p2a[1]) - radius), 0, int(upper)),
min(int(max(p1a[0], p2a[0]) + radius + 1), 18),
min(int(max(p1a[1], p2a[1]) + radius + 1), 15, int(lower) + 1),
)
rasterize(bitmap, p1a, p2a, bounds) # Render ellipse into buffer
# If the eye is currently blinking, and if the top edge of the
# eyelid overlaps the bitmap, draw a scanline across the bitmap
# and update the bounds rect so the whole width of the bitmap
# is scaled.
if blink_state and upper >= 0:
bitmap[int(upper)] = eyelid
bounds = (0, int(upper), 18, bounds[3])
eye.smooth(bitmap, bounds) # 1:3 downsampling for eye
# Matrix and rings share a few pixels. To make the rings take
# precedence, they're drawn later. So blink state is revisited now...
if blink_state: # In mid-blink?
for i in range(13): # Half an LED ring, top-to-bottom...
a = min(max(y_pos[i] - upper + 1, 0), 3)
b = min(max(lower - y_pos[i] + 1, 0), 3)
ratio = a * b / 9 # Proximity of LED to eyelid edges
packed = interp(ring_open_color, ring_blink_color, ratio)
glasses.left_ring[i] = glasses.right_ring[i] = packed
if 0 < i < 12:
i = 24 - i # Mirror half-ring to other side
glasses.left_ring[i] = glasses.right_ring[i] = packed
else:
glasses.left_ring.fill(ring_open_color_packed)
glasses.right_ring.fill(ring_open_color_packed)
glasses.show() # Buffered mode MUST use show() to refresh matrix
except OSError: # See "try" notes above regarding rare I2C errors.
print("Restarting")
reload()
frames += 1
elapsed = time.monotonic() - start_time
#print(frames / elapsed)
Page last edited September 14, 2026
Text editor powered by tinymce.