Skip to content

Commit 4aa19e0

Browse files
authored
Merge pull request #3196 from videopixil/glass_lamp
3D Printed Stained Glass Lamp
2 parents 91d76ed + 8b17fad commit 4aa19e0

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed

code.py

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
# SPDX-FileCopyrightText: Adafruit Industries
2+
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
3+
#
4+
# SPDX-License-Identifier: MIT
5+
6+
# Code written by Adafruit Industries
7+
# Adafruit Circuit Playground Express Bluefruit
8+
9+
# pylint: disable=global-statement
10+
11+
import time
12+
import math
13+
import array
14+
import board
15+
import digitalio
16+
import neopixel
17+
import analogio
18+
import audiobusio
19+
import touchio
20+
import busio
21+
import adafruit_lis3dh
22+
23+
from adafruit_ble import BLERadio
24+
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
25+
from adafruit_ble.services.nordic import UARTService
26+
27+
from adafruit_bluefruit_connect.packet import Packet
28+
from adafruit_bluefruit_connect.color_packet import ColorPacket
29+
from adafruit_bluefruit_connect.button_packet import ButtonPacket
30+
import adafruit_fancyled.adafruit_fancyled as fancy
31+
32+
# setup pixels
33+
pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=1, auto_write=True)
34+
35+
# name colors so you don't need to refer to numbers
36+
RED = (255, 0, 0)
37+
BLACK = (0, 0, 0)
38+
GREEN = (0, 255, 0)
39+
PURPLE = (100, 0, 255)
40+
BLUE = (0, 0, 255)
41+
42+
# Declare a 6-element RGB rainbow palette
43+
PALETTE_RAINBOW = [fancy.CRGB(1.0, 0.0, 0.0), # Red
44+
fancy.CRGB(0.5, 0.5, 0.0), # Yellow
45+
fancy.CRGB(0.0, 1.0, 0.0), # Green
46+
fancy.CRGB(0.0, 0.5, 0.5), # Cyan
47+
fancy.CRGB(0.0, 0.0, 1.0), # Blue
48+
fancy.CRGB(0.5, 0.0, 0.5)] # Magenta
49+
50+
NUM_LEDS = 10
51+
offset = 0 # animation position offset
52+
active_palette = None # currently running palette animation
53+
active_color = None # currently breathing solid color
54+
55+
def update_palette():
56+
"""Advance one frame of the active palette animation."""
57+
global offset
58+
if active_palette is None:
59+
return
60+
for i in range(NUM_LEDS):
61+
color = fancy.palette_lookup(active_palette, offset + i / NUM_LEDS)
62+
color = fancy.gamma_adjust(color, brightness=0.25)
63+
pixels[i] = color.pack()
64+
pixels.show()
65+
offset += 0.05
66+
67+
def update_breathing():
68+
"""Slowly breathe the active solid color between brightness 0.2 and 0.5."""
69+
if active_color is None:
70+
return
71+
# Sine wave oscillates 0-1, scale to 0.2-0.5 range
72+
brightness = 0.35 + 0.15 * math.sin(time.monotonic() * 1.5)
73+
r = int(active_color[0] * brightness)
74+
g = int(active_color[1] * brightness)
75+
b = int(active_color[2] * brightness)
76+
pixels.fill((r, g, b))
77+
78+
# --- VU Meter (audio reactive) setup ---
79+
mic = audiobusio.PDMIn(
80+
board.MICROPHONE_CLOCK, board.MICROPHONE_DATA,
81+
sample_rate=16000, bit_depth=16)
82+
samples = array.array('H', [0] * 320)
83+
84+
CURVE = 2
85+
SCALE_EXPONENT = math.pow(10, CURVE * -0.1)
86+
87+
def constrain(value, floor, ceiling):
88+
return max(floor, min(value, ceiling))
89+
90+
def log_scale(input_value, input_min, input_max, output_min, output_max):
91+
normalized_input_value = (input_value - input_min) / (input_max - input_min)
92+
return output_min + math.pow(normalized_input_value, SCALE_EXPONENT) * (output_max - output_min)
93+
last_vu_input = 0
94+
active_vu = False # VU meter mode flag
95+
96+
# VU meter colors mapped to 10 NeoPixels
97+
VU_GREEN = (0, 127, 0)
98+
VU_YELLOW = (127, 127, 0)
99+
VU_RED = (127, 0, 0)
100+
VU_OFF = (0, 0, 0)
101+
vu_colors = [VU_GREEN, VU_GREEN, VU_GREEN, VU_GREEN,
102+
VU_YELLOW, VU_YELLOW, VU_YELLOW,
103+
VU_RED, VU_RED, VU_RED]
104+
105+
def mean(values):
106+
"""Average of mic sample values."""
107+
return sum(values) / len(values)
108+
109+
def normalized_rms(values):
110+
"""Return normalized RMS of mic samples."""
111+
minbuf = int(mean(values))
112+
samples_sum = sum(
113+
float(sample - minbuf) * (sample - minbuf)
114+
for sample in values
115+
)
116+
return math.sqrt(samples_sum / len(values))
117+
118+
vu_level = 0.0 # smoothed VU level
119+
120+
def update_vu():
121+
"""Update NeoPixels based on mic input level with smooth rise and fall."""
122+
global last_vu_input, vu_level, input_floor, input_ceiling
123+
if not active_vu:
124+
return
125+
mic.record(samples, len(samples))
126+
magnitude = normalized_rms(samples)
127+
# Adaptive noise floor: continuously tracks ambient noise
128+
# (including BLE radio EMI) so the meter stays zeroed.
129+
if magnitude < input_floor:
130+
# Below floor — floor drifts down slowly
131+
input_floor = input_floor * 0.999 + magnitude * 0.001
132+
elif magnitude < input_floor + 4:
133+
# Near the floor — this is still noise, nudge floor up
134+
input_floor = input_floor * 0.9 + magnitude * 0.1
135+
input_ceiling = input_floor + 15.0
136+
# Compute scaled logarithmic reading in the range 0 to NUM_LEDS
137+
target = log_scale(
138+
constrain(magnitude, input_floor, input_ceiling),
139+
input_floor, input_ceiling, 0, NUM_LEDS)
140+
# Smooth: rise slowly, fall even slower
141+
if target > vu_level:
142+
vu_level = vu_level + (target - vu_level) * 0.3
143+
else:
144+
vu_level = vu_level + (target - vu_level) * 0.12
145+
input_val = int(vu_level)
146+
if last_vu_input != input_val:
147+
pixels.fill(VU_OFF)
148+
for i in range(min(input_val, NUM_LEDS)):
149+
pixels[i] = vu_colors[i]
150+
pixels.show()
151+
last_vu_input = input_val
152+
153+
# Sentinel for VU meter mode in animation list
154+
VU_METER = "VU_METER"
155+
156+
# --- Light Sensor setup ---
157+
light = analogio.AnalogIn(board.LIGHT)
158+
active_light = False # Light sensor mode flag
159+
light_level = 0.0 # smoothed light level
160+
161+
# Light meter warm colors
162+
LIGHT_DIM = (52, 5, 1)
163+
LIGHT_BRIGHT = (9, 5, 4)
164+
165+
last_light_color = (0, 0, 0) # track last written color
166+
167+
def update_light():
168+
"""All 10 LEDs blend between dim and bright color based on light level."""
169+
global light_level, last_light_color
170+
if not active_light:
171+
return
172+
# 0.0 = dark room (dim color), 1.0 = bright room (bright warm color)
173+
raw = light.value
174+
target = max(0.0, min(1.0, (raw - 1000) / 1000.0))
175+
# Smooth: very gentle transitions
176+
if target > light_level:
177+
light_level = light_level + (target - light_level) * 0.02
178+
else:
179+
light_level = light_level + (target - light_level) * 0.015
180+
# Clamp to prevent drift
181+
light_level = max(0.0, min(1.0, light_level))
182+
t = light_level
183+
new_color = (int(LIGHT_DIM[0] + (LIGHT_BRIGHT[0] - LIGHT_DIM[0]) * t),
184+
int(LIGHT_DIM[1] + (LIGHT_BRIGHT[1] - LIGHT_DIM[1]) * t),
185+
int(LIGHT_DIM[2] + (LIGHT_BRIGHT[2] - LIGHT_DIM[2]) * t))
186+
# Only update pixels if the color actually changed
187+
if new_color != last_light_color:
188+
last_light_color = new_color
189+
pixels.fill(new_color)
190+
pixels.show()
191+
192+
# Sentinel for Light meter mode in animation list
193+
LIGHT_METER = "LIGHT_METER"
194+
195+
# Calibrate: seed the adaptive noise floor
196+
mic.record(samples, len(samples))
197+
input_floor = normalized_rms(samples) + 10
198+
input_ceiling = input_floor + 15.0
199+
200+
# setup bluetooth
201+
ble = BLERadio()
202+
uart_service = UARTService()
203+
advertisement = ProvideServicesAdvertisement(uart_service)
204+
205+
# setup physical buttons
206+
button_a = digitalio.DigitalInOut(board.D4)
207+
button_a.direction = digitalio.Direction.INPUT
208+
button_a.pull = digitalio.Pull.DOWN
209+
210+
button_b = digitalio.DigitalInOut(board.D5)
211+
button_b.direction = digitalio.Direction.INPUT
212+
button_b.pull = digitalio.Pull.DOWN
213+
214+
# Capacitive touch pads for brightness
215+
touch_bright = touchio.TouchIn(board.A1) # D6 - increase brightness
216+
touch_dim = touchio.TouchIn(board.A2) # D9 - decrease brightness
217+
prev_touch_bright = False
218+
prev_touch_dim = False
219+
220+
# Setup accelerometer for tap detection
221+
accelo_i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
222+
accelo = adafruit_lis3dh.LIS3DH_I2C(accelo_i2c, address=0x19)
223+
accelo.set_tap(1, 100) # single tap, threshold 100 (medium tap)
224+
225+
# Lists for cycling
226+
COLOR_LIST = [PURPLE, GREEN, RED, BLUE, LIGHT_METER]
227+
PALETTE_LIST = [PALETTE_RAINBOW, VU_METER]
228+
ALL_MODES = [PURPLE, GREEN, RED, BLUE, LIGHT_METER,
229+
PALETTE_RAINBOW, VU_METER]
230+
color_index = 0
231+
palette_index = 0
232+
all_modes_index = ALL_MODES.index(LIGHT_METER) + 1 # next mode after light meter
233+
BRIGHTNESS_STEP = 0.1
234+
prev_button_a = False
235+
prev_button_b = False
236+
237+
def apply_mode(selection):
238+
"""Apply a mode from any list, clearing all other modes."""
239+
global active_palette, active_color, active_vu, active_light
240+
global vu_level, last_vu_input, light_level, last_light_color
241+
global input_floor, input_ceiling
242+
active_palette = None
243+
active_color = None
244+
active_vu = False
245+
active_light = False
246+
if selection == VU_METER:
247+
vu_level = 0.0
248+
last_vu_input = 0
249+
pixels.fill(VU_OFF)
250+
pixels.show()
251+
# Brief settle, then seed the adaptive floor
252+
time.sleep(0.15)
253+
for _ in range(3):
254+
mic.record(samples, len(samples))
255+
mic.record(samples, len(samples))
256+
input_floor = normalized_rms(samples) + 10
257+
input_ceiling = input_floor + 15.0
258+
active_vu = True
259+
elif selection == LIGHT_METER:
260+
light_level = 0.0
261+
last_light_color = (0, 0, 0)
262+
active_light = True
263+
elif isinstance(selection, list):
264+
active_palette = selection
265+
else:
266+
active_color = selection
267+
268+
while True:
269+
# set CPXb up so that it can be discovered by the app
270+
ble.start_advertising(advertisement)
271+
# Start with light meter mode
272+
apply_mode(LIGHT_METER)
273+
_ = accelo.tapped # clear any startup tap
274+
time.sleep(0.5) # brief delay to ignore boot vibration
275+
while not ble.connected:
276+
# Check physical buttons while waiting
277+
if button_a.value and not prev_button_a:
278+
apply_mode(COLOR_LIST[color_index])
279+
color_index = (color_index + 1) % len(COLOR_LIST)
280+
if button_b.value and not prev_button_b:
281+
apply_mode(PALETTE_LIST[palette_index])
282+
palette_index = (palette_index + 1) % len(PALETTE_LIST)
283+
prev_button_a = button_a.value
284+
prev_button_b = button_b.value
285+
# Check capacitive touch for brightness
286+
if touch_bright.value and not prev_touch_bright:
287+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
288+
if touch_dim.value and not prev_touch_dim:
289+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
290+
prev_touch_bright = touch_bright.value
291+
prev_touch_dim = touch_dim.value
292+
# Check accelerometer tap to cycle modes
293+
if accelo.tapped:
294+
apply_mode(ALL_MODES[all_modes_index])
295+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
296+
update_palette()
297+
update_breathing()
298+
update_vu()
299+
update_light()
300+
time.sleep(0.02)
301+
302+
# Now we're connected
303+
304+
while ble.connected:
305+
# Check physical buttons
306+
if button_a.value and not prev_button_a:
307+
apply_mode(COLOR_LIST[color_index])
308+
color_index = (color_index + 1) % len(COLOR_LIST)
309+
if button_b.value and not prev_button_b:
310+
apply_mode(PALETTE_LIST[palette_index])
311+
palette_index = (palette_index + 1) % len(PALETTE_LIST)
312+
prev_button_a = button_a.value
313+
prev_button_b = button_b.value
314+
# Check capacitive touch for brightness
315+
if touch_bright.value and not prev_touch_bright:
316+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
317+
if touch_dim.value and not prev_touch_dim:
318+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
319+
prev_touch_bright = touch_bright.value
320+
prev_touch_dim = touch_dim.value
321+
# Check accelerometer tap to cycle modes
322+
if accelo.tapped:
323+
apply_mode(ALL_MODES[all_modes_index])
324+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
325+
326+
# Keep animating the active mode
327+
update_palette()
328+
update_breathing()
329+
update_vu()
330+
update_light()
331+
332+
if uart_service.in_waiting:
333+
try:
334+
packet = Packet.from_stream(uart_service)
335+
except ValueError:
336+
continue # or pass.
337+
338+
if isinstance(packet, ColorPacket): # check if a color was sent from color picker
339+
active_palette = None
340+
active_color = None
341+
active_vu = False
342+
active_light = False
343+
pixels.fill(packet.color)
344+
if isinstance(packet, ButtonPacket): # check if a button was pressed from control pad
345+
if packet.pressed:
346+
if packet.button == ButtonPacket.BUTTON_1: # Rainbow palette
347+
apply_mode(PALETTE_RAINBOW)
348+
if packet.button == ButtonPacket.BUTTON_2: # VU Meter
349+
apply_mode(VU_METER)
350+
if packet.button == ButtonPacket.BUTTON_3: # Purple
351+
apply_mode(PURPLE)
352+
if packet.button == ButtonPacket.BUTTON_4: # Light Meter
353+
apply_mode(LIGHT_METER)
354+
if packet.button == ButtonPacket.UP: # Brighten
355+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
356+
if packet.button == ButtonPacket.DOWN: # Dim
357+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
358+
if packet.button == ButtonPacket.LEFT: # Cycle modes backward
359+
all_modes_index = (all_modes_index - 1) % len(ALL_MODES)
360+
apply_mode(ALL_MODES[all_modes_index])
361+
if packet.button == ButtonPacket.RIGHT: # Cycle modes forward
362+
apply_mode(ALL_MODES[all_modes_index])
363+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
364+
365+
time.sleep(0.02) # small delay for smooth animation

0 commit comments

Comments
 (0)