Servus, dear community! Live from Munich, the birthplace of Oktoberfest—first celebrated in 1810 and still the biggest folk fest on Earth.
Picture it: brass bands thundering, pretzels as big as your face, and steins clinking in perfect rhythm as friends shout “Prost!” (Bavarian cheers!). Because at Oktoberfest, beer is a vibe.
Now say hello to the Oktoberfest Special 🥨🍻 Smart Beer Stein: a 3D‑printed, gyro‑powered stein that reacts to your moves. Raise it, toast with it, dance with it—this stein brings the celebration to life.
HardwareAll you need for this fun project is a 3D‑printed beer stein with OKTOBERFEST text, a 3D inner stein and LED holder, a drinking glass that sits inside for a 100% safe experience, and the PSOC™ 6 board and battery housed in the 3D undercup attached to the stein!
Don´t worry, all the 3D design files will be listed below at the end of the article😉
Step1: Snap the LED matrix into the slot in the inner stein—it’s perfectly designed so you don’t need glue. Make sure the cables run through the gap as shown in the pictures.
Then carefully slide it down the stein.
Step 2 : Connect the electronics together.
Step 3: Add strong double‑sided tape on the outside bottom of the glass for extra security while drinking. This also lets you remove the glass - with a bit of force- so you can wash it. ;)Then slide the glass into the glass holder.
And there you have it—your beautiful Oktoberfest Smart Beer Stein!
Schematics
Our electronics setup is quite simple; since our gyroscope/accelerometer are already integrated in the PSOC™ 6 board, all you need is an LED matrix for the animation!
First of all, if you want to learn how to get started with the gyroscope, you can check out our protip here.
And for this project, we will separate the code explanation into two parts:
Firstpart: LED animation
We start by importing the basics:
from machine import I2C, Pin
import neopixel
import urandom as random
import random
Then we define an 8×32 matrix (256 LEDs). PIN_NAME is the output pin.
W = 8
H = 32
N = W * H
PIN_NAME = "P9_7"
np = neopixel.NeoPixel(Pin(PIN_NAME), N)
We provide convenience functions: clear()
to black the frame buffer, write()
to flush to the strip, set_num()
and set_nums()
using 1‑based indices (matching your art layout), and scale_color()
for brightness gradients without exceeding 8‑bit color.
def clear():
for i in range(N):
np[i] = (0, 0, 0)
def write():
np.write()
def set_num(n, color):
if 1 <= n <= N:
np[n - 1] = color
def set_nums(numbers, color):
for n in numbers:
if 1 <= n <= N:
np[n - 1] = color
def scale_color(color, scale):
r, g, b = color
return (int(clamp(r * scale, 0, 255)),
int(clamp(g * scale, 0, 255)),
int(clamp(b * scale, 0, 255)))
Animation 1: Dreamy Hearts
HEART_BASE = [22,12,4,3,15,19,31,35,36,28]
HEART_STEP = 48 # one step = 6 rows
HEART_COLOR = (255, 0, 80)
HEART_BASE
lists the “heart” pixels for one motif. HEART_STEP
shifts the motif vertically down the matrix per multiplier (our layout treats 48 as one motif spacing). HEART_COLOR
is the pink-ish shade.
def heart_indices_with_multiplier(m, step=HEART_STEP):
offset = m * step
shifted = [n + offset for n in HEART_BASE]
return [nn for nn in shifted if 1 <= nn <= N]
def draw_hearts_multipliers(mults, color=HEART_COLOR, scale=1.0):
c = scale_color(color, scale)
for m in mults:
idxs = heart_indices_with_multiplier(m)
if idxs:
set_nums(idxs, c)
We shift the base motif by m×step and clip indices to the panel. draw_hearts_multipliers
draws multiple shifted motifs with a common color and brightness scale.
class HeartsAnim:
def __init__(self, group_a=(1,3), group_b=(2,4), steps=16, step_ms=30, color=HEART_COLOR):
self.group_a = group_a
self.group_b = group_b
self.steps = max(1, steps)
self.step_ms = step_ms
self.color = color
self.t_step = 0
self.phase_a_to_b = True
self.last_ms = 0
def reset(self):
self.t_step = 0
self.phase_a_to_b = True
self.last_ms = 0
def step(self, now):
if self.last_ms and dt(now, self.last_ms) < self.step_ms:
return
self.last_ms = now
p = self.t_step / float(self.steps)
if not self.phase_a_to_b:
p = 1.0 - p
alpha_a = p
alpha_b = 1.0 - p
clear()
draw_hearts_multipliers(self.group_a, self.color, alpha_a)
draw_hearts_multipliers(self.group_b, self.color, alpha_b)
write()
self.t_step += 1
if self.t_step > self.steps:
self.t_step = 0
self.phase_a_to_b = not self.phase_a_to_b
HeartsAnim
crossfades two motif groups: group_a
and group_b
fade opposite each other. The step_ms
throttles updates, and steps
controls fade granularity. We clear, draw both with complementary alphas, write, and flip the direction at the end of each cycle.
We then apply the same principle for the next three animations:
Animation 2: Big Pretzel (breathing + chase + sparkles)
PRETZEL_PATH = [105,103,91,85,77,78,82,95,98,109,110,108,102,87,74,71,54,44,45,46,50,63,66,69,59,55,41]
PRETZEL_BASE_COLOR = (153, 76, 0)
PRETZEL_CHASE_COLOR = (255, 200, 40)
PRETZEL_SPARKLE_COLOR = (255, 229, 204)
PRETZEL_PATH
is the set of pixels forming the big pretzel outline. We use a warm base color, a brighter chase color, and a pale sparkle.
class BigPrezelDreamyAnim:
def __init__(self,
step_ms=25,
breath_min=0.25, breath_max=0.9, breath_step=0.06,
head_speed=1, trail_len=7, tail_decay=0.68,
sparkle_prob=0.25, sparkle_decay=0.86,
mults=(0,), step_spacing=105):
...
self.path = PRETZEL_PATH[:]
self.L = len(self.path)
self.breath = breath_min
self.breath_dir = +1
self.head = 0
self.sparkle_levels = {}
self.last_ms = 0
- “Breathing”: base brightness oscillates between breath_min and breath_max by breath_step.
- “Chase”: a head moves along the pretzel path with a decaying trail (trail_len, tail_decay).
- “Sparkles”: random flickers along the path with a decay rate
Animation 3: Small Pretzel crossfade
For this animation, we are also going to draw the path of the small pretzel, multiply it and do a crossfade animation. The detailed code will be down below, we will not go through it in the article so it is easier to read.
SMALL_PRETZEL_PATH = [86,75,70,59,53,52,62,67,77,91,85,101,100,94,83,103,55,69,59]
Animation 4: Beer crossfade (yellow + white head)
BEER_YELLOW_IDX = [100,103,104,102,101,93,84,77,76,75,74,73,88,89,69,59,71]
BEER_WHITE_IDX = [112,98,95,96,82,80]
BEER_STEP = 48
The animation follows the same crossfade principle. To keep this article concise, we won’t repeat the full explanation again here—you’ll find the code below, and it’s straightforward to follow.
Now, to tie the gyroscope states and the animations together, we use an Animator manager:
class Animator:
def __init__(self):
self.anims = {
"CHEERS": HeartsAnim(...),
"DRINKING": BeerAnim(...),
"PICKED_UP": SmallPretzelAnim(...),
"ON_TABLE": BigPretzelDreamyAnim(...),
}
self.current_state = None
self.current_anim = None
def set_state(self, new_state):
if new_state != self.current_state:
self.current_state = new_state
self.current_anim = self.anims.get(new_state)
if self.current_anim:
self.current_anim.reset()
def step(self, now):
if self.current_anim:
self.current_anim.step(now)
We create an Animator that selects an animation per state and resets it on transitions.
CHEERS uses hearts, DRINKING uses beer, PICKED_UP uses the small pretzel crossfade, and ON_TABLE uses the big pretzel.
This clean separation keeps the main loop tidy.
Second part : Gyroscope/Accelerometer
To start with the second part, we need to import the missing basics:
import time, math
from machine import I2C
import bmi270
from statistics import mean
As well as some physics constants and tiny helpers:
G = 9.80665
def ms(): return time.ticks_ms()
def dt(a,b): return time.ticks_diff(a,b)
def sleep_ms(x): time.sleep_ms(x)
def mag(x,y,z): return math.sqrt(x*x + y*y + z*z)
def angle_deg(ax, ay, az, bx, by, bz):
am = mag(ax, ay, az)
bm = mag(bx, by, bz)
if am == 0 or bm == 0: return 0.0
c = max(-1.0, min(1.0, (ax*bx + ay*by + az*bz) / (am * bm)))
return math.degrees(math.acos(c))
- G is the standard gravity constant (m/s²). It lets you express acceleration as “g” units by dividing by G.
ms()
,dt()
,sleep_ms()
: convenience wrappers around MicroPython’s millisecond timing functions.mag()
: Euclidean magnitude of a 3D vector; used for both acceleration and gyro magnitude.angle_deg()
: Computes the angle between two 3D vectors (in degrees), clamping the dot product so acos stays in a safe range. This is used to measure “tilt” relative to a calibrated gravity vector.
SAMPLE_MS = 20 # ~50 Hz
LIFT_TILT_DEG = 15.0
MOVE_GYRO_DPS = 60.0
TABLE_GYRO_QUIET= 12.0
TABLE_JERK_QUIET= 0.08
PUTDOWN_DWELL = 800
STICKY_AFTER_MOVE = 1000
DRINK_TILT_DEG = 55.0
DRINK_HOLD_MS = 400
DRINK_EXIT_HYS = 12.0
CHEERS_GYRO_MIN = 80.0 # dps
CHEERS_JERK_THRESHOLD = 0.05 # g/s
CHEERS_MIN_INTERVAL = 900 # ms between cheers
CHEERS_DURATION_MS = 1000 # ms to stay in CHEERS state
The main loop sleeps 20 ms between reads, so your sampling rate is ~50 samples per second. This is a good balance for responsiveness and battery life.
LIFT_TILT_DEG
: minimum tilt that indicates the stein is picked up.MOVE_GYRO_DPS
: minimum gyro magnitude (degrees/sec) that indicates movement/pickup even without tilt.TABLE_GYRO_QUIET
andTABLE_JERK_QUIET
: how still it must be to count as “resting on the table.”PUTDOWN_DWELL
: how long the stein must remain quiet/upright before declaring ON_TABLE.STICKY_AFTER_MOVE:
a guard time to avoid toggling states too quickly after movement.DRINK_TILT_DEG
: tilt angle that indicates “drinking” posture.DRINK_HOLD_MS
: how long the drinking tilt must be held to enter DRINKING.DRINK_EXIT_HYS
: hysteresis to prevent rapid DRINKING ↔ PICKED_UP flapping as tilt crosses the threshold.CHEERS_*
: thresholds for detecting a “cheers” gesture as a quick, jerky clink—requires both a gyro peak and Z‑jerk peak, with minimum spacing and a fixed celebration duration.
Then we initialize the pin and do a sensor setup:
i2c = I2C(scl='P0_2', sda='P0_3')
bmi = bmi270.BMI270(i2c)
We later start with the calibration:
N = 64
print("Calibrating... keep stein flat & still.")
sx = sy = sz = 0.0
for _ in range(N):
ax, ay, az = bmi.accel()
sx += ax; sy += ay; sz += az
sleep_ms(SAMPLE_MS)
g0x, g0y, g0z = sx/N, sy/N, sz/N
print("Calibration done.")
We samples the accelerometer N times while the stein rests flat and still. We average those readings to get g0x,g0y,g0z, z—the reference gravity vector in the stein’s local frame.
This reference lets us compute tilt as the angle between the current acceleration vector and the calibrated gravity vector, which makes tilt robust across small mounting variances.
After that, we set the state variables:
state = "ON_TABLE"; print("ON_TABLE")
drink_since = None
putdown_since = None
last_motion = ms()
on_table_since = ms()
last_cheers = -999999
cheers_since = None
prev_acc_mag = mag(*bmi.accel())
prev_az = bmi.accel()[2]
We initialize the state machine and timers. drink_since
marks when drinking tilt started (to enforce DRINK_HOLD_MS
). putdown_since
marks when quiet/upright was first detected (to enforce PUTDOWN_DWELL
). last_motion
tracks the last time movement occurred (for STICKY_AFTER_MOVE
). on_table_since
tracks time since the last ON_TABLE
. last_cheers
enforces spacing between CHEERS detections. cheers_since
controls CHEERS duration. prev_acc_mag
and prev_az
hold previous readings used to compute jerk.
Smoothing buffers:
gyro_buffer = []
jerk_z_buffer = []
Main loop (sensor read, features, state machine and animations):
while True:
t = ms()
# Read sensors
ax, ay, az = bmi.accel()
gx, gy, gz = bmi.gyro()
gyro_mag = mag(gx, gy, gz)
acc_mag = mag(ax, ay, az)
jerk_g = abs(acc_mag - prev_acc_mag) / G
prev_acc_mag = acc_mag
tilt = angle_deg(ax, ay, az, g0x, g0y, g0z)
acc_z_g = az / G
jerk_z = abs(az - prev_az) / G
prev_az = az
We read sensors and compute:
gyro_mag
in dps,acc_mag
andjerk_g
in g/s for general motion,tilt
versus gravity,Z
‑axis jerk for detecting clink peaks.
# Update buffers
gyro_buffer.append(gyro_mag)
jerk_z_buffer.append(jerk_z)
if len(gyro_buffer) > 3: gyro_buffer.pop(0)
if len(jerk_z_buffer) > 3: jerk_z_buffer.pop(0)
gyro_avg = mean(gyro_buffer)
jerk_z_avg = mean(jerk_z_buffer)
We keep the last three samples. We compute averages (this is not strictly needed for the current logic, but handy for future smoothing or debug).
# Motion bookkeeping for sticky-on-table logic
if (gyro_mag > TABLE_GYRO_QUIET) or (jerk_g > TABLE_JERK_QUIET):
last_motion = t
We update last_motion when motion is above quiet thresholds. This supports the STICKY_AFTER_MOVE
guard, avoiding instant ON_TABLE transitions right after movement.
# ---------- CHEERS: peak detection ----------
if (max(gyro_buffer) >= CHEERS_GYRO_MIN and
max(jerk_z_buffer) > CHEERS_JERK_THRESHOLD and
t - last_cheers > CHEERS_MIN_INTERVAL):
last_cheers = t
cheers_since = t
state = "CHEERS"
animator.set_state(state)
drink_since = None
putdown_since = None
print("CHEERS")
We detect a “cheers” when both recent gyro and Z‑jerk exceed thresholds, with minimum spacing between triggers. We enter CHEERS and reset timing variables (the Animator switches to hearts).
# ---------- Exit CHEERS after duration ----------
if state == "CHEERS" and cheers_since is not None:
if (t - cheers_since) >= CHEERS_DURATION_MS:
cheers_since = None
if tilt >= DRINK_TILT_DEG:
state = "DRINKING"; print("DRINKING")
elif tilt >= LIFT_TILT_DEG or gyro_mag >= MOVE_GYRO_DPS:
state = "PICKED_UP"; print("PICKED_UP")
else:
state = "ON_TABLE"; print("ON_TABLE")
animator.set_state(state)
After the hearts have been up long enough, we exit CHEERS into the most plausible next state based on current tilt/motion—DRINKING if heavily tilted, else PICKED_UP, else ON_TABLE.
# ---------- DRINKING detection ----------
if state != "CHEERS":
if tilt >= DRINK_TILT_DEG:
if drink_since is None:
drink_since = t
elif (t - drink_since) >= DRINK_HOLD_MS and state != "DRINKING":
state = "DRINKING"; print("DRINKING")
animator.set_state(state)
putdown_since = None
else:
drink_since = None
if state == "DRINKING" and tilt <= (DRINK_TILT_DEG - DRINK_EXIT_HYS):
state = "PICKED_UP"; print("PICKED_UP")
animator.set_state(state)
We enter DRINKING only after holding the high tilt for DRINK_HOLD_MS
, preventing false positives. We exit DRINKING with hysteresis back to PICKED_UP so the glass can transition gracefully.
# ---------- PICKED_UP / ON_TABLE ----------
if state not in ["DRINKING", "CHEERS"]:
if (tilt >= LIFT_TILT_DEG) or (gyro_mag >= MOVE_GYRO_DPS):
if state != "PICKED_UP":
state = "PICKED_UP"; print("PICKED_UP")
animator.set_state(state)
putdown_since = None
on_table_since = t
else:
quiet_upright = (tilt <= LIFT_TILT_DEG and gyro_mag <= TABLE_GYRO_QUIET and jerk_g <= TABLE_JERK_QUIET)
sticky_ok = (t - last_motion) >= STICKY_AFTER_MOVE
if quiet_upright and sticky_ok:
if putdown_since is None:
putdown_since = t
elif (t - putdown_since) >= PUTDOWN_DWELL:
if state != "ON_TABLE":
state = "ON_TABLE"; print("ON_TABLE")
animator.set_state(state)
putdown_since = None
on_table_since = t
else:
putdown_since = None
If we’re not drinking or cheering, we decide between PICKED_UP and ON_TABLE. Movement or tilt pushes us to PICKED_UP (small pretzel animation). Otherwise, if we remain quiet/upright long enough (with the sticky guard), we settle into ON_TABLE and show the big pretzel dreamy animation.
animator.step(t)
time.sleep_ms(SAMPLE_MS)
We advance the active animation at our chosen step rate and keep the sensor loop at ~50 Hz. Each animation has its own internal throttling (step_ms) to avoid overdriving the LEDs.
FarewellO’zapft is! The stein is smart, the lights are bright, and the party is on. Thanks for reading and tinkering—now go raise your stein and let the animation do the talking. Tschüüüs 😄
Comments