Skip to content

Commit 471a149

Browse files
authored
Merge pull request #3203 from firepixie/Masquerade_n00ds
Create code.py
2 parents 13f1648 + 7e78f13 commit 471a149

File tree

1 file changed

+384
-0
lines changed

1 file changed

+384
-0
lines changed

Masquerade_n00ds/code.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
# SPDX-FileCopyrightText: Erin St. Blaine for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
Tilt-Triggered Antler Sweep with Idle Breathing Glow
6+
====================================================
7+
8+
What this does
9+
--------------
10+
This project uses an AW9523 constant-current LED driver to animate 3V LED n00ds
11+
arranged as "antlers", plus a center "rose" symbol. A Prop-Maker FeatherWing's
12+
LIS3DH accelerometer detects head tilts:
13+
14+
- While you're not tilting: the antlers "breathe" (pulse) between two brightness levels.
15+
- Tilt your head LEFT -> antlers sweep LEFT-to-RIGHT, then back (a "down and back" sweep).
16+
- Tilt your head RIGHT -> antlers sweep RIGHT-to-LEFT, then back.
17+
- A cooldown prevents re-triggering too quickly.
18+
19+
Hardware
20+
--------
21+
- RP2040 Prop-Maker Feather (LIS3DH accelerometer)
22+
- AW9523 GPIO expander + constant-current LED driver (I2C)
23+
- 7x 3V LED n00ds (wired to AW9523 LED outputs)
24+
25+
Wiring notes
26+
------------
27+
- AW9523 connects via I2C (STEMMA QT / Qwiic works great).
28+
- Each n00d's + goes to your power rail (3V), and - goes to an AW9523 LED pin.
29+
- The center rose symbol is on AW9523 pin 9 in this example.
30+
31+
How to tune
32+
-----------
33+
All the numbers you’ll likely tweak live in the USER SETTINGS section:
34+
- TILT_AXIS and thresholds: for your board orientation on the headpiece
35+
- BLINK_OFF_TIME / BLINK_GAP_TIME: sweep feel
36+
- IDLE_LOW / IDLE_HIGH / IDLE_STEP / IDLE_DELAY: breathing glow speed + depth
37+
- MIN_SECONDS_BETWEEN_TRIGGERS: cooldown
38+
39+
Tip: Start with DEBUG_PRINT = True, open the Serial Monitor, tilt your head, and
40+
watch the axis values. Then adjust thresholds until it feels reliable.
41+
"""
42+
43+
import time
44+
45+
import adafruit_aw9523
46+
import adafruit_lis3dh
47+
import board
48+
import busio
49+
50+
# ============================================================
51+
# USER SETTINGS (edit these first)
52+
# ============================================================
53+
54+
# --- Brightness (0-255) ---
55+
OFF = 0
56+
DIM = int(255 * 0.60) # Base level used during the sweep (returns to after each blink)
57+
ROSE = 255 # Rose stays on continuously once it fades in
58+
59+
# --- Rose fade-in ---
60+
ROSE_FADE_S = 0.90
61+
FADE_STEPS = 80 # Higher = smoother fade (slightly more CPU time)
62+
63+
# --- Sweep timing ---
64+
BLINK_OFF_TIME = 0.18 # How long each n00d goes dark (OFF) during the sweep
65+
BLINK_GAP_TIME = 0.04 # How long to wait after returning to DIM before moving to the next n00d
66+
67+
# --- Idle "breathing" pulse ---
68+
IDLE_LOW = int(255 * 0.40)
69+
IDLE_HIGH = 255
70+
IDLE_STEP = 3 # Smaller = smoother/slower pulse; larger = faster pulse
71+
IDLE_DELAY = 0.015 # Larger = slower pulse; smaller = faster pulse
72+
73+
# --- Cooldown between triggers ---
74+
MIN_SECONDS_BETWEEN_TRIGGERS = 3.0
75+
76+
# --- Tilt detection ---
77+
TILT_AXIS = "x" # Change to "y" or "z" depending on how the board is mounted
78+
TILT_RIGHT_THRESHOLD = +6.0 # Tilt direction "right" if axis value >= this threshold
79+
TILT_LEFT_THRESHOLD = -6.0 # Tilt direction "left" if axis value <= this threshold
80+
CONFIRM_SAMPLES = 4 # Require sustained tilt for this many samples
81+
82+
# --- Debug printing (Serial Monitor) ---
83+
DEBUG_PRINT = True
84+
PRINT_EVERY_N_SAMPLES = 2
85+
86+
# ============================================================
87+
# PIN MAPPING (AW9523 pins your n00ds are connected to)
88+
# ============================================================
89+
90+
# Antler groups (by your physical layout)
91+
OUTER = [0, 1]
92+
MIDDLE = [2, 11]
93+
INNER = [3, 10]
94+
ANTLERS = INNER + MIDDLE + OUTER
95+
96+
# Center symbol (rose)
97+
SYMBOL_PIN = 9
98+
SYMBOL = [SYMBOL_PIN]
99+
100+
# List of every AW9523 channel we touch
101+
USED = ANTLERS + SYMBOL
102+
103+
# Sweep order across the antlers (left -> right).
104+
SWEEP_L2R = [1, 2, 3, 10, 11, 0]
105+
SWEEP_R2L = list(reversed(SWEEP_L2R))
106+
107+
108+
def down_and_back(order):
109+
"""
110+
Convert a one-way sweep list into a "down and back" path without repeating the end pin.
111+
112+
Example:
113+
[A, B, C, D] -> [A, B, C, D, C, B]
114+
"""
115+
if len(order) < 2:
116+
return order
117+
return order + order[-2:0:-1]
118+
119+
120+
# ============================================================
121+
# HARDWARE SETUP (I2C + devices)
122+
# ============================================================
123+
124+
# Shared I2C bus (STEMMA QT port uses board.SCL/board.SDA)
125+
i2c = busio.I2C(board.SCL, board.SDA)
126+
127+
# AW9523 constant-current LED driver
128+
aw = adafruit_aw9523.AW9523(i2c)
129+
print("Found AW9523")
130+
131+
# Put all pins into LED (constant-current) mode and configure as outputs
132+
aw.LED_modes = 0xFFFF
133+
aw.directions = 0xFFFF
134+
135+
# LIS3DH accelerometer (Prop-Maker FeatherWing)
136+
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c)
137+
lis3dh.range = adafruit_lis3dh.RANGE_4_G
138+
print("Found LIS3DH")
139+
140+
# ============================================================
141+
# AW9523 HELPERS (set brightness, fades, and applying updates)
142+
# ============================================================
143+
144+
# Keep track of each channel's current brightness so we can fade smoothly
145+
levels = {p: 0 for p in USED}
146+
147+
148+
def apply_levels():
149+
"""Push our current 'levels' dict out to the AW9523."""
150+
for pin, value in levels.items():
151+
aw.set_constant_current(pin, value)
152+
153+
154+
def set_all(value=0):
155+
"""Set all used channels to one brightness value (0-255)."""
156+
value = max(0, min(255, int(value)))
157+
for pin in USED:
158+
levels[pin] = value
159+
apply_levels()
160+
161+
162+
def set_group(group, value):
163+
"""Set a group of channels (list of pins) to one brightness value (0-255)."""
164+
value = max(0, min(255, int(value)))
165+
for pin in group:
166+
levels[pin] = value
167+
apply_levels()
168+
169+
170+
def fade_group_to(group, target, seconds):
171+
"""
172+
Fade a group of channels from their current brightness to 'target' over 'seconds'.
173+
"""
174+
target = max(0, min(255, int(target)))
175+
start = levels[group[0]] # groups stay aligned in this project
176+
177+
if start == target or seconds <= 0:
178+
for pin in group:
179+
levels[pin] = target
180+
apply_levels()
181+
return
182+
183+
dt = seconds / FADE_STEPS
184+
for step_i in range(FADE_STEPS + 1):
185+
t = step_i / FADE_STEPS
186+
v = int(start + (target - start) * t)
187+
for pin in group:
188+
levels[pin] = v
189+
apply_levels()
190+
time.sleep(dt)
191+
192+
193+
# ============================================================
194+
# IDLE ANIMATION (breathing/pulsing antlers)
195+
# ============================================================
196+
197+
def idle_pulse_step(pulse_dir, value):
198+
"""
199+
Advance the idle pulse by one small step.
200+
201+
pulse_dir: +1 or -1
202+
value: current brightness value
203+
204+
Returns updated (pulse_dir, value).
205+
"""
206+
value += pulse_dir * IDLE_STEP
207+
208+
if value >= IDLE_HIGH:
209+
value = IDLE_HIGH
210+
pulse_dir = -1
211+
elif value <= IDLE_LOW:
212+
value = IDLE_LOW
213+
pulse_dir = 1
214+
215+
# Apply pulse to antlers only (rose stays on)
216+
for pin in ANTLERS:
217+
levels[pin] = value
218+
apply_levels()
219+
220+
return pulse_dir, value
221+
222+
223+
# ============================================================
224+
# SWEEP ANIMATION (blink OFF in a path)
225+
# ============================================================
226+
227+
def blink_off(pin):
228+
"""
229+
Blink a single antler channel OFF briefly, then return it to DIM.
230+
"""
231+
levels[pin] = OFF
232+
apply_levels()
233+
time.sleep(BLINK_OFF_TIME)
234+
235+
levels[pin] = DIM
236+
apply_levels()
237+
time.sleep(BLINK_GAP_TIME)
238+
239+
240+
def run_sweep_once(order):
241+
"""
242+
Run exactly one down-and-back sweep (no repeated end pin).
243+
244+
order: a list of pins that defines the sweep direction (e.g. SWEEP_L2R).
245+
"""
246+
# Bring all antlers to a consistent base brightness for a clean sweep
247+
set_group(ANTLERS, DIM)
248+
249+
path = down_and_back(order)
250+
if DEBUG_PRINT:
251+
print("Sweep path:", path)
252+
253+
for pin in path:
254+
blink_off(pin)
255+
256+
257+
# ============================================================
258+
# TILT DETECTION (with idle pulsing while waiting)
259+
# ============================================================
260+
261+
def pick_axis(ax, ay, az, which):
262+
"""Return the chosen axis value from the LIS3DH reading."""
263+
if which == "x":
264+
return ax
265+
if which == "y":
266+
return ay
267+
return az # "z"
268+
269+
270+
def wait_for_tilt_with_idle_pulse():
271+
"""
272+
Idle animation + tilt detection in one loop.
273+
274+
While waiting:
275+
- antlers pulse between IDLE_LOW and IDLE_HIGH
276+
277+
Returns:
278+
"left" if tilted left
279+
"right" if tilted right
280+
"""
281+
right_hot = 0
282+
left_hot = 0
283+
sample_count = 0
284+
285+
# Start the idle pulse at the low end
286+
pulse_dir = 1
287+
pulse_val = IDLE_LOW
288+
289+
if DEBUG_PRINT:
290+
print("\n--- Idling (pulsing) + waiting for tilt ---")
291+
292+
while True:
293+
# 1) Do one small idle pulse step
294+
pulse_dir, pulse_val = idle_pulse_step(pulse_dir, pulse_val)
295+
296+
# 2) Read accel + evaluate tilt
297+
ax, ay, az = lis3dh.acceleration
298+
axis_val = pick_axis(ax, ay, az, TILT_AXIS)
299+
300+
# Right tilt
301+
if axis_val >= TILT_RIGHT_THRESHOLD:
302+
right_hot += 1
303+
else:
304+
right_hot = max(0, right_hot - 1)
305+
306+
# Left tilt
307+
if axis_val <= TILT_LEFT_THRESHOLD:
308+
left_hot += 1
309+
else:
310+
left_hot = max(0, left_hot - 1)
311+
312+
# Optional debug printing (Serial Monitor) - f-string to satisfy pylint
313+
if DEBUG_PRINT and (sample_count % PRINT_EVERY_N_SAMPLES == 0):
314+
print(
315+
f"{TILT_AXIS}={axis_val:6.2f} | "
316+
f"right_hot={right_hot} left_hot={left_hot} | "
317+
f"idle={pulse_val}"
318+
)
319+
320+
# Trigger if the tilt is sustained long enough
321+
if right_hot >= CONFIRM_SAMPLES:
322+
if DEBUG_PRINT:
323+
print(">>> TILT RIGHT DETECTED <<<")
324+
return "right"
325+
326+
if left_hot >= CONFIRM_SAMPLES:
327+
if DEBUG_PRINT:
328+
print(">>> TILT LEFT DETECTED <<<")
329+
return "left"
330+
331+
sample_count += 1
332+
time.sleep(IDLE_DELAY)
333+
334+
335+
# ============================================================
336+
# BOOT SEQUENCE
337+
# ============================================================
338+
339+
# Start everything off
340+
set_all(OFF)
341+
342+
# Fade the rose up once, then leave it on
343+
print("Boot: fading rose up...")
344+
fade_group_to(SYMBOL, ROSE, ROSE_FADE_S)
345+
print("Rose is ON.")
346+
347+
# Start antlers at a consistent level before we begin idling
348+
set_group(ANTLERS, DIM)
349+
350+
# Allow an immediate trigger on startup
351+
last_trigger_time = time.monotonic() - MIN_SECONDS_BETWEEN_TRIGGERS
352+
353+
# ============================================================
354+
# MAIN LOOP
355+
# ============================================================
356+
357+
while True:
358+
# Wait for a tilt while idling (pulsing) in the background
359+
tilt_direction = wait_for_tilt_with_idle_pulse()
360+
361+
# Cooldown gate (prevents rapid retriggering)
362+
now = time.monotonic()
363+
if (now - last_trigger_time) < MIN_SECONDS_BETWEEN_TRIGGERS:
364+
if DEBUG_PRINT:
365+
print("(cooldown) ignoring trigger")
366+
continue
367+
last_trigger_time = now
368+
369+
# NOTE: Your current mapping is intentionally swapped:
370+
# - tilt_direction == "left" runs the L->R sweep
371+
# - tilt_direction == "right" runs the R->L sweep
372+
# This is handy when the board is mounted "flipped" on the headpiece.
373+
if tilt_direction == "left":
374+
if DEBUG_PRINT:
375+
print("\n>>> SWEEP: LEFT -> RIGHT -> BACK <<<")
376+
run_sweep_once(SWEEP_L2R)
377+
378+
elif tilt_direction == "right":
379+
if DEBUG_PRINT:
380+
print("\n>>> SWEEP: RIGHT -> LEFT -> BACK <<<")
381+
run_sweep_once(SWEEP_R2L)
382+
383+
# Return to a known base brightness; the idle pulse will take over immediately.
384+
set_group(ANTLERS, DIM)

0 commit comments

Comments
 (0)