seveibar/led-water-accelerometer
This is an LED matrix with an accelerometer and a PICO. When you tilt the PCB, the LEDs dim and change color to simulate water flowing.
- Version
- 1.0.18
- License
- unset
- Stars
- 7
firmware/tscircuit-motion-blue-shades.py
from machine import Pin, Timer, SPI
import neopixel
import time
import math
import struct
import random
import rp2
# ───── LED Setup ─────
LED_PIN = 6
COLS, ROWS = 7, 6 # 7×6 LED matrix
NUM_LEDS = COLS * ROWS
np = neopixel.NeoPixel(Pin(LED_PIN), NUM_LEDS)
# On-board LED for feedback
onboard_led = Pin('LED', Pin.OUT)
# ───── BOOTSEL button globals ─────
last_button_state = 0
current_mode = 0
switch_requested = False
total_presses = 0
def check_bootsel_button(timer):
"""Timer callback to read BOOTSEL and request a mode switch"""
global last_button_state, current_mode, switch_requested, total_presses
current_state = rp2.bootsel_button()
# rising edge = press
if current_state == 1 and last_button_state == 0:
total_presses += 1
current_mode = (current_mode + 1) % 2
switch_requested = True
print(f"🎬 BOOTSEL pressed → mode {current_mode} (press #{total_presses})")
onboard_led.on()
show_switch_feedback()
# falling edge = release
elif current_state == 0 and last_button_state == 1:
print("🔄 BOOTSEL released")
onboard_led.off()
last_button_state = current_state
def show_switch_feedback():
"""Flash the whole matrix twice when the mode changes"""
for _ in range(2):
for i in range(NUM_LEDS):
np[i] = (10, 10, 10)
np.write()
time.sleep(0.1)
for i in range(NUM_LEDS):
np[i] = (0, 0, 0)
np.write()
time.sleep(0.1)
# ════════════════════════════════════════════════════════════════════════
# ───── Scrolling-text animation (Mode 0) ─────
# ════════════════════════════════════════════════════════════════════════
class NeoPixelMatrix:
def __init__(self, strip, cols, rows):
self.np = strip
self.cols = cols
self.rows = rows
self.clear()
def xy_to_index(self, x, y):
return y * self.cols + x
def clear(self):
for i in range(NUM_LEDS):
self.np[i] = (0, 0, 0)
self.np.write()
def set_pixel(self, x, y, color):
if 0 <= x < self.cols and 0 <= y < self.rows:
self.np[self.xy_to_index(x, y)] = color
# Variable-width 3- to 4-pixel glyphs (all 6 rows tall)
LETTER_PATTERNS = {
'T': [
[1,1,1],
[0,1,0],
[0,1,0],
[0,1,0],
[0,1,0],
[0,1,0],
],
'S': [
[0,1,1,1],
[1,0,0,0],
[0,1,1,0],
[0,0,0,1],
[1,0,0,1],
[0,1,1,0],
],
'C': [
[0,1,1,1],
[1,0,0,0],
[1,0,0,0],
[1,0,0,0],
[1,0,0,0],
[0,1,1,1],
],
'I': [
[1,1,1],
[0,1,0],
[0,1,0],
[0,1,0],
[0,1,0],
[1,1,1],
],
'R': [
[1,1,1,0],
[1,0,0,1],
[1,1,1,0],
[1,0,1,0],
[1,0,0,1],
[1,0,0,1],
],
'U': [
[1,0,0,1],
[1,0,0,1],
[1,0,0,1],
[1,0,0,1],
[1,0,0,1],
[0,1,1,0],
],
}
# Color palette for each letter - bright and vibrant!
LETTER_COLORS = {
'T': (50, 0, 0), # Red
'S': (50, 25, 0), # Orange
'C': (50, 50, 0), # Yellow
'I': (0, 50, 0), # Green
'R': (0, 0, 50), # Blue
'C': (25, 0, 50), # Purple (second C gets different color)
'U': (50, 0, 25), # Magenta
'I': (0, 50, 25), # Cyan (second I gets different color)
'T': (50, 15, 15), # Pink (second T gets different color)
}
# Different shades of blue for each letter
def get_letter_color(letter, position):
"""Get color for a letter based on its position in the text - various shades of blue"""
blue_shades = [
(10, 20, 50), # Deep blue - T
(5, 15, 40), # Dark blue - S
(15, 25, 45), # Medium-dark blue - C
(20, 30, 50), # Medium blue - I
(25, 35, 55), # Medium-light blue - R
(15, 30, 40), # Steel blue - C
(10, 25, 45), # Navy blue - U
(30, 40, 60), # Light blue - I
(20, 35, 50), # Bright blue - T
]
return blue_shades[position % len(blue_shades)]
def draw_letter_to_buffer(buf, letter, start_x, start_y, color):
pat = LETTER_PATTERNS.get(letter)
if not pat:
return
for y, row in enumerate(pat):
for x, px in enumerate(row):
if px:
sx, sy = start_x + x, start_y + y
if 0 <= sx < COLS and 0 <= sy < ROWS:
buf[sy][sx] = color
def update_matrix_from_buffer(matrix, buf):
for y in range(ROWS):
for x in range(COLS):
matrix.set_pixel(x, y, buf[y][x] if buf[y][x] else (0, 0, 0))
matrix.np.write()
def scroll_text_continuous_animation():
"""Mode 0: Scroll 'TSCIRCUIT' with variable-width glyphs and different colors"""
global switch_requested
matrix = NeoPixelMatrix(np, COLS, ROWS)
text = "TSCIRCUIT"
speed = 0.1
letter_spacing = 1
print("🎬 Mode 0 → Blue shades scrolling text")
while not switch_requested:
# compute full marquee width
total_width = (
sum(len(LETTER_PATTERNS[c][0]) for c in text)
+ (len(text) - 1) * letter_spacing
)
start_x = COLS
end_x = -total_width
pos = float(start_x)
frame = [[0 for _ in range(COLS)] for _ in range(ROWS)]
while pos > end_x and not switch_requested:
# clear buffer
for y in range(ROWS):
for x in range(COLS):
frame[y][x] = 0
# draw each glyph at its current x-offset with unique color
cx = pos
for i, ch in enumerate(text):
color = get_letter_color(ch, i)
draw_letter_to_buffer(frame, ch, int(cx), 0, color)
cx += len(LETTER_PATTERNS[ch][0]) + letter_spacing
update_matrix_from_buffer(matrix, frame)
pos -= 1.0
time.sleep(speed)
if not switch_requested:
time.sleep(0.2)
# ════════════════════════════════════════════════════════════════════════
# ───── Particle-physics animation (Mode 1) … unchanged ─────
# ════════════════════════════════════════════════════════════════════════
MAX_LEVEL = 50
COLOR_CH = (0, 0, 1) # blue particles
PARTICLE_COUNT = 25
PARTICLE_RADIUS = 0.5
SCK_PIN, MOSI_PIN, MISO_PIN, CS_PIN = 10, 11, 12, 17
def init_accelerometer():
spi = SPI(1, baudrate=1_000_000, polarity=1, phase=1,
sck=Pin(SCK_PIN), mosi=Pin(MOSI_PIN), miso=Pin(MISO_PIN))
cs = Pin(CS_PIN, Pin.OUT, value=1)
def _w(addr, val):
cs(0); spi.write(bytearray([addr & 0x3F, val & 0xFF])); cs(1)
def _r(addr, n=1):
cmd = addr | 0x80 | (0x40 if n > 1 else 0)
cs(0); spi.write(bytearray([cmd])); data = spi.read(n); cs(1); return data
try:
if _r(0x0F)[0] != 0x33:
raise RuntimeError("No LIS3DHTR")
_w(0x20, 0x57)
_w(0x23, 0x08)
print("✅ Accelerometer OK")
return spi, cs, _w, _r
except:
print("⚠️ Accelerometer not found, simulating gravity")
return None, None, None, None
class Particle:
__slots__ = ("x", "y", "vx", "vy")
def __init__(self):
self.x = random.uniform(1, COLS - 1)
self.y = random.uniform(1, ROWS - 1)
self.vx = random.uniform(-0.2, 0.2)
self.vy = random.uniform(-0.2, 0.2)
def update(self, gx, gy, buckets):
self.vx += gx * 0.2
self.vy += gy * 0.2
self.x += self.vx
self.y += self.vy
if self.x < PARTICLE_RADIUS or self.x > COLS - PARTICLE_RADIUS:
self.vx *= -0.7
self.x = max(PARTICLE_RADIUS, min(COLS - PARTICLE_RADIUS, self.x))
if self.y < PARTICLE_RADIUS or self.y > ROWS - PARTICLE_RADIUS:
self.vy *= -0.7
self.y = max(PARTICLE_RADIUS, min(ROWS - PARTICLE_RADIUS, self.y))
self.vx *= 0.92
self.vy *= 0.92
gx_i, gy_i = int(self.x), int(self.y)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
nx, ny = gx_i + dx, gy_i + dy
if 0 <= nx < COLS and 0 <= ny < ROWS:
for other in buckets[ny][nx]:
if other is self:
continue
dx_d = other.x - self.x
dy_d = other.y - self.y
dist_sq = dx_d*dx_d + dy_d*dy_d
min_d = PARTICLE_RADIUS * 2
if 0.01 < dist_sq < min_d * min_d:
dist = math.sqrt(dist_sq)
if dist > 0:
overlap = min_d - dist
nxn, nyn = dx_d / dist, dy_d / dist
self.x -= nxn * overlap * 0.5
self.y -= nyn * overlap * 0.5
self.vx -= nxn * 0.1
self.vy -= nyn * 0.1
def update_leds(gx, gy, particles, brt, buckets):
# clear brightness + buckets
for y in range(ROWS):
for x in range(COLS):
brt[y][x] = 0.0
buckets[y][x].clear()
# assign to buckets
for p in particles:
bx, by = int(p.x), int(p.y)
if 0 <= bx < COLS and 0 <= by < ROWS:
buckets[by][bx].append(p)
# physics update
for p in particles:
p.update(gx, gy, buckets)
# LED influence
for p in particles:
gx_i, gy_i = int(p.x), int(p.y)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
nx, ny = gx_i + dx, gy_i + dy
if 0 <= nx < COLS and 0 <= ny < ROWS:
dx_d = abs(p.x - (nx + 0.5))
dy_d = abs(p.y - (ny + 0.5))
if dx_d < 1.2 and dy_d < 1.2:
influence = max(0, 1.0 - (dx_d + dy_d) * 0.7)
brt[ny][nx] += influence * 1.5
# push to strip
for y in range(ROWS):
for x in range(COLS):
idx = y * COLS + x
lvl = int(min(brt[y][x] * 6, MAX_LEVEL))
np[idx] = (lvl * COLOR_CH[0],
lvl * COLOR_CH[1],
lvl * COLOR_CH[2])
np.write()
def particle_physics_animation():
"""Mode 1: bouncing particles affected by gravity / accelerometer"""
global switch_requested
print("🎬 Mode 1 → Particle physics")
spi, cs, _w, _r = init_accelerometer()
particles = [Particle() for _ in range(PARTICLE_COUNT)]
brightness = [[0.0 for _ in range(COLS)] for _ in range(ROWS)]
buckets = [[[] for _ in range(COLS)] for _ in range(ROWS)]
LSB_G = 0.00098
G_CLAMP = 15
sim_time = 0.0
while not switch_requested:
if spi:
try:
raw = _r(0x28, 6)
xr, yr, zr = struct.unpack("<hhh", raw)
ax = max(min(xr * LSB_G, G_CLAMP), -G_CLAMP)
ay = max(min(yr * LSB_G, G_CLAMP), -G_CLAMP)
az = zr * LSB_G
mag = math.sqrt(ax*ax + ay*ay)
if mag > 0.05:
gx, gy = ax / mag, -ay / mag
else:
gx, gy = 0.0, 1.0 if az > 0 else -1.0
except:
gx = 0.0
gy = 1.0
else:
# simulated gravity sweeps around slowly
sim_time += 0.1
gx = math.sin(sim_time * 0.3) * 0.5
gy = math.cos(sim_time * 0.2) * 0.5
update_leds(gx, gy, particles, brightness, buckets)
# unthrottled for max FPS
# ════════════════════════════════════════════════════════════════════════
# ───── Main program & mode switching ─────
# ════════════════════════════════════════════════════════════════════════
def main():
global switch_requested
# poll BOOTSEL @ 50 Hz
btn_timer = Timer()
btn_timer.init(freq=50, mode=Timer.PERIODIC, callback=check_bootsel_button)
animations = [scroll_text_continuous_animation,
particle_physics_animation]
names = ["Blue Shades Scrolling Text", "Particle Physics"]
print("\n🎮 BOOTSEL animation switcher")
print("Press BOOTSEL to cycle modes")
print("-" * 40)
try:
while True:
print(f"\n>>> starting mode {current_mode}: {names[current_mode]}")
switch_requested = False
animations[current_mode]()
time.sleep(0.1) # small gap between modes
except KeyboardInterrupt:
print("\n🛑 stopped by user | total BOOTSEL presses:", total_presses)
finally:
# clear LEDs & tidy up
for i in range(NUM_LEDS):
np[i] = (0, 0, 0)
np.write()
onboard_led.off()
btn_timer.deinit()
print("✅ cleanup done")
if __name__ == "__main__":
main()