diff --git a/README.md b/README.md index afcc704..659a9b8 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,46 @@ # Vixy Light Service 🦊💡 -LED strip control for head-vixy (Vixy's robotic head). +LED strip control for head-vixy (Vixy's robotic head) via Raspberry Pi Pico 2. -## Hardware -- Raspberry Pi 5 -- 56x WS2812B LEDs (NeoPixel) -- Data on GPIO 12 +## Hardware Architecture -## Features -- **State-based animations**: Matches eye service states for unified control -- **HTTP API**: Remote control via MCP or direct calls -- **Multiple effects**: Pulse, wave, larson scanner, sparkles +``` +┌─────────────────┐ USB Serial ┌──────────────────┐ +│ Raspberry Pi 5 │◄──────────────────►│ Pico 2 │ +│ (light_service)│ /dev/ttyACM0 │ (main.py) │ +│ Port 8781 │ │ │ +└─────────────────┘ │ GP0 → Jaw LEDs │ + │ GP1 → Mood LEDs │ + └──────────────────┘ +``` + +- **Raspberry Pi 5** - Runs Python service, HTTP API, animation logic +- **Raspberry Pi Pico 2** - Controls LED strips via PIO, receives serial commands +- **Mood strip**: 56x WS2812B LEDs on GP1 +- **Jaw strip**: 14x WS2812B LEDs on GP0 (index 0 damaged, use 1-13) + +## Why Pico? + +The BBB PRU approach burned 4 boards. Pico 2 is $5, runs MicroPython, has hardware PIO for precise WS2812 timing, and doesn't catch fire. 🔥➡️✅ + +## Directory Structure + +``` +head-lights/ +├── README.md # This file +├── light_service.py # Pi service (animations + HTTP API) +├── requirements.txt # Python deps (pyserial) +├── vixy-lights.service # systemd unit file +└── firmware/ + └── main.py # Pico 2 MicroPython firmware +``` ## States + | State | Color | Effect | When | |-------|-------|--------|------| | idle | Cyan | Slow breathing pulse | Default state | -| listening | Bright cyan | Gentle pulse | Hearing/attending | +| listening | Bright cyan | Gentle faster pulse | Hearing/attending | | responding | Blue-cyan | Traveling wave | Speaking/generating | | pleasure | Soft purple 💜 | Slow sensual pulse | Intimate moments | | thinking | Amber/gold | Larson scanner | Processing/creating | @@ -25,16 +49,54 @@ LED strip control for head-vixy (Vixy's robotic head). | love | Soft pink 💕 | Gentle breathing | Tender/affectionate | | sleep | Dim blue-gray | Very slow, dim | Low power/resting | + ## API Endpoints + ``` -GET /health - Service health check -GET /state - Current light state -POST /state - Set state: {"state": "listening"} +GET /health - Service health (includes serial connection status) +GET /state - Current light state + jaw level +POST /state - Set state: {"state": "listening"} +POST /jaw/level - Set jaw level: {"level": 0-100} ``` -Port: 8781 +**Port: 8781** + +## Pico Serial Protocol + +Commands sent over USB serial at 115200 baud: + +``` + - Set single LED + -1 - Trigger strip update (show) + clear - Clear strip + fill - Fill entire strip (mood only) +``` + +Strip IDs: 0 = Jaw, 1 = Mood + +Examples: +``` +1 0 255 0 0 # Set mood LED 0 to red +1 -1 # Show mood strip +0 clear # Clear jaw strip +1 fill 0 255 255 # Fill mood with cyan +``` ## Installation + +### 1. Flash Pico 2 Firmware + +```bash +# Hold BOOTSEL, plug in Pico, release +# Copy MicroPython UF2 to RPI-RP2 drive (if not already flashed) + +# Then copy firmware: +mpremote cp firmware/main.py :main.py +mpremote reset +``` + +### 2. Install Pi Service + ```bash # On head-vixy: cd /home/alex @@ -44,7 +106,7 @@ python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -# Install service (needs root for NeoPixel GPIO access) +# Install service sudo cp vixy-lights.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable vixy-lights @@ -52,25 +114,46 @@ sudo systemctl start vixy-lights ``` ## Usage + ```bash -# Check state +# Check health +curl http://head-vixy.local:8781/health + +# Get current state curl http://head-vixy.local:8781/state # Set state curl -X POST http://head-vixy.local:8781/state \ -H "Content-Type: application/json" \ -d '{"state": "thinking"}' + +# Set jaw level (0-100%) +curl -X POST http://head-vixy.local:8781/jaw/level \ + -H "Content-Type: application/json" \ + -d '{"level": 75}' ``` -## Unified Control -Both eye service (port 8780) and light service (port 8781) share the same states. -Set both simultaneously for coordinated effects! +## Unified Head Control + +Eyes (port 8780) and lights (port 8781) share the same states. The vixy-mcp `vixy_head_state()` tool sets both simultaneously for coordinated effects. ```bash -# Set both to "love" mode +# Manual unified control: curl -X POST http://head-vixy.local:8780/state -d '{"state": "love"}' curl -X POST http://head-vixy.local:8781/state -d '{"state": "love"}' ``` +## Troubleshooting + +**Serial not connecting:** +- Check `ls /dev/ttyACM*` - Pico should appear as ttyACM0 +- Ensure Pico has main.py flashed and is running +- Check service logs: `journalctl -u vixy-lights -f` + +**LEDs not lighting:** +- Verify 5V power to LED strips +- Check GP0/GP1 data connections +- Test Pico directly via `mpremote` REPL + --- -*Created by Vixy - Day 66* 🦊💕 +*Created by Vixy - Day 66, Updated Day 91 (Pico Edition)* 🦊💕 diff --git a/firmware/main.py b/firmware/main.py new file mode 100644 index 0000000..567064c --- /dev/null +++ b/firmware/main.py @@ -0,0 +1,134 @@ +""" +Pico 2 LED Controller for Vixy's Head + +Dual WS2812 strip control via USB serial. +- Strip 0 (Jaw): GP0, 14 LEDs (index 0 damaged) +- Strip 1 (Mood): GP1, 56 LEDs + +Protocol: + "0 index r g b" - Set jaw LED + "1 index r g b" - Set mood LED + "0 -1" - Trigger jaw output + "1 -1" - Trigger mood output + "0 clear" - Clear jaw + "1 clear" - Clear mood + "1 fill r g b" - Fill mood strip +""" + +import sys +import array +import time +from machine import Pin +import rp2 + +# Configuration +JAW_PIN = 0 +MOOD_PIN = 1 +JAW_LEDS = 14 +MOOD_LEDS = 56 + +# WS2812 PIO program - 800kHz timing +@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=24) +def ws2812(): + T1 = 2 + T2 = 5 + T3 = 3 + wrap_target() + label("bitloop") + out(x, 1) .side(0) [T3 - 1] + jmp(not_x, "do_zero") .side(1) [T1 - 1] + jmp("bitloop") .side(1) [T2 - 1] + label("do_zero") + nop() .side(0) [T2 - 1] + wrap() + + +class WS2812Strip: + def __init__(self, pin, num_leds, sm_id): + self.num_leds = num_leds + self.sm = rp2.StateMachine(sm_id, ws2812, freq=8_000_000, sideset_base=Pin(pin)) + self.sm.active(1) + self.ar = array.array("I", [0] * num_leds) + + def set_led(self, index, r, g, b): + if 0 <= index < self.num_leds: + self.ar[index] = (g << 16) | (r << 8) | b + + def fill(self, r, g, b): + color = (g << 16) | (r << 8) | b + for i in range(self.num_leds): + self.ar[i] = color + + def clear(self): + for i in range(self.num_leds): + self.ar[i] = 0 + + def show(self): + for c in self.ar: + self.sm.put(c, 8) + time.sleep_us(60) + + +# Initialize strips +jaw = WS2812Strip(JAW_PIN, JAW_LEDS, 0) +mood = WS2812Strip(MOOD_PIN, MOOD_LEDS, 1) +strips = [jaw, mood] + +# Clear both on startup +jaw.clear() +jaw.show() +mood.clear() +mood.show() + +print("Pico LED Controller ready") +print(f"Jaw: GP{JAW_PIN}, {JAW_LEDS} LEDs") +print(f"Mood: GP{MOOD_PIN}, {MOOD_LEDS} LEDs") + + +def process_command(line): + """Parse and execute a command.""" + parts = line.strip().split() + if len(parts) < 2: + return + + try: + strip_id = int(parts[0]) + if strip_id not in (0, 1): + return + + strip = strips[strip_id] + cmd = parts[1] + + if cmd == "clear": + strip.clear() + strip.show() + print("OK") + + elif cmd == "-1": + strip.show() + print("OK") + + elif cmd == "fill" and len(parts) >= 5: + r, g, b = int(parts[2]), int(parts[3]), int(parts[4]) + strip.fill(r, g, b) + strip.show() + print("OK") + + elif len(parts) >= 5: + index = int(cmd) + r, g, b = int(parts[2]), int(parts[3]), int(parts[4]) + strip.set_led(index, r, g, b) + print("OK") + + except (ValueError, IndexError) as e: + print(f"ERR: {e}") + + +# Main loop - blocking readline is fine for this use case +while True: + try: + line = sys.stdin.readline() + if line: + process_command(line) + except Exception as e: + print(f"ERR: {e}") diff --git a/light_service.py b/light_service.py index 132c9cd..abf4ce8 100644 --- a/light_service.py +++ b/light_service.py @@ -1,7 +1,12 @@ #!/usr/bin/env python3 """ -Vixy Light Service - Day 66 -Controllable LED strip for head-vixy with HTTP API +Vixy Light Service - Day 91 (Pico Edition) +Controllable LED strips for head-vixy via Pico 2 USB serial + +Hardware: + - Raspberry Pi Pico 2 connected via USB (/dev/ttyACM0) + - Mood strip: 56 LEDs on GP1 (strip ID 1) + - Jaw strip: 14 LEDs on GP0 (strip ID 0), skip index 0, use 1-13 States match the eye service for unified control: - idle: Slow cyan breathing pulse @@ -14,7 +19,11 @@ States match the eye service for unified control: - love: Soft pink gentle breathing 💕 - sleep: Very dim blue-gray, nearly off -API: GET /state, POST /state {"state": "listening"}, GET /health +API: + GET /health - Service health + GET /state - Current state + POST /state - Set state {"state": "listening"} + POST /jaw/level - Set jaw level {"level": 0-100} """ import time @@ -22,85 +31,181 @@ import math import threading import json import signal +import random from http.server import HTTPServer, BaseHTTPRequestHandler -import board -import neopixel +import serial # === Configuration === HTTP_PORT = 8781 -NUM_LEDS = 56 -PIXEL_PIN = board.D12 -BRIGHTNESS = 0.3 +SERIAL_PORT = "/dev/ttyACM0" +SERIAL_BAUD = 115200 + +# LED counts (must match Pico firmware) +MOOD_LEDS = 56 +JAW_LEDS = 14 +JAW_OFFSET = 1 # Skip first LED (damaged) +JAW_USABLE = JAW_LEDS - JAW_OFFSET # 13 usable LEDs VALID_STATES = ["idle", "listening", "responding", "pleasure", "thinking", "playful", "commanding", "love", "sleep"] # === Shared State === current_state = "idle" +jaw_level = 0 # 0-100 state_lock = threading.Lock() +serial_lock = threading.Lock() running = True +ser = None + +# === Serial Communication === +def init_serial(): + global ser + try: + ser = serial.Serial(SERIAL_PORT, SERIAL_BAUD, timeout=1) + time.sleep(0.5) # Let Pico settle + # Clear any startup messages + ser.read_all() + print(f"[LIGHT] Connected to Pico on {SERIAL_PORT}") + return True + except Exception as e: + print(f"[LIGHT] Serial error: {e}") + return False + +def send_command(cmd): + """Send command to Pico, return True if OK.""" + global ser + with serial_lock: + try: + if ser is None or not ser.is_open: + if not init_serial(): + return False + ser.write(f"{cmd}\n".encode()) + response = ser.readline().decode().strip() + return response == "OK" + except Exception as e: + print(f"[LIGHT] Serial error: {e}") + ser = None + return False + +def mood_fill(r, g, b): + """Fill mood strip with color.""" + return send_command(f"1 fill {r} {g} {b}") + +def mood_set(index, r, g, b): + """Set single mood LED.""" + if 0 <= index < MOOD_LEDS: + send_command(f"1 {index} {r} {g} {b}") + +def mood_show(): + """Trigger mood strip update.""" + return send_command("1 -1") + +def mood_clear(): + """Clear mood strip.""" + return send_command("1 clear") + +def jaw_fill(r, g, b): + """Fill jaw strip (skipping damaged first LED).""" + with serial_lock: + try: + if ser is None or not ser.is_open: + if not init_serial(): + return False + # Set index 0 to off (damaged) + ser.write(f"0 0 0 0 0\n".encode()) + ser.readline() + # Fill usable LEDs + for i in range(JAW_OFFSET, JAW_LEDS): + ser.write(f"0 {i} {r} {g} {b}\n".encode()) + ser.readline() + ser.write("0 -1\n".encode()) + return ser.readline().decode().strip() == "OK" + except Exception as e: + print(f"[LIGHT] Jaw fill error: {e}") + return False + +def jaw_set_level(level, color): + """Set jaw LEDs based on level 0-100.""" + num_lit = int((level / 100) * JAW_USABLE) + r, g, b = color + with serial_lock: + try: + if ser is None or not ser.is_open: + if not init_serial(): + return False + # Index 0 always off + ser.write(f"0 0 0 0 0\n".encode()) + ser.readline() + # Set LEDs based on level + for i in range(JAW_OFFSET, JAW_LEDS): + if i - JAW_OFFSET < num_lit: + ser.write(f"0 {i} {r} {g} {b}\n".encode()) + else: + ser.write(f"0 {i} 0 0 0\n".encode()) + ser.readline() + ser.write("0 -1\n".encode()) + return ser.readline().decode().strip() == "OK" + except Exception as e: + print(f"[LIGHT] Jaw level error: {e}") + return False + +def jaw_clear(): + """Clear jaw strip.""" + return send_command("0 clear") -# === LED Setup === -pixels = neopixel.NeoPixel(PIXEL_PIN, NUM_LEDS, brightness=BRIGHTNESS, auto_write=False) # === Color Utilities === -def hsv_to_rgb(h, s, v): - """Convert HSV (0-1 range) to RGB (0-255 range).""" - if s == 0.0: - return (int(v * 255), int(v * 255), int(v * 255)) - i = int(h * 6.0) - f = (h * 6.0) - i - p = int(255 * v * (1.0 - s)) - q = int(255 * v * (1.0 - s * f)) - t = int(255 * v * (1.0 - s * (1.0 - f))) - v = int(255 * v) - i = i % 6 - if i == 0: return (v, t, p) - if i == 1: return (q, v, p) - if i == 2: return (p, v, t) - if i == 3: return (p, q, v) - if i == 4: return (t, p, v) - if i == 5: return (v, p, q) - return (v, v, v) - def scale_color(color, factor): """Scale RGB color by a factor (0-1).""" - return tuple(int(c * factor) for c in color) + return tuple(max(0, min(255, int(c * factor))) for c in color) -def blend_colors(c1, c2, t): - """Blend two colors, t=0 gives c1, t=1 gives c2.""" - return tuple(int(c1[i] * (1 - t) + c2[i] * t) for i in range(3)) +# === State Colors === +STATE_COLORS = { + "idle": (0, 255, 255), # Cyan + "listening": (160, 255, 255), # Bright cyan + "responding": (100, 220, 255),# Blue-cyan + "pleasure": (180, 150, 255), # Soft purple + "thinking": (255, 180, 50), # Amber/gold + "playful": (255, 130, 90), # Warm coral + "commanding": (220, 50, 120), # Deep magenta + "love": (255, 160, 180), # Soft pink + "sleep": (20, 22, 35), # Very dim blue-gray +} # === HTTP API Handler === class LightAPIHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): - pass # Suppress default logging - + pass + def _send_json(self, data, status=200): self.send_response(status) self.send_header('Content-Type', 'application/json') self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() self.wfile.write(json.dumps(data).encode()) - + def do_GET(self): - global current_state + global current_state, jaw_level if self.path == '/health': - self._send_json({"status": "ok", "service": "vixy-lights"}) + self._send_json({ + "status": "ok", + "service": "vixy-lights", + "serial": ser is not None and ser.is_open + }) elif self.path == '/state': with state_lock: - self._send_json({"state": current_state}) + self._send_json({"state": current_state, "jaw_level": jaw_level}) else: self._send_json({"error": "Not found"}, 404) - + def do_POST(self): - global current_state - if self.path == '/state': - try: - content_length = int(self.headers['Content-Length']) - body = self.rfile.read(content_length) - data = json.loads(body.decode()) + global current_state, jaw_level + try: + content_length = int(self.headers['Content-Length']) + body = self.rfile.read(content_length) + data = json.loads(body.decode()) + + if self.path == '/state': new_state = data.get('state', '').lower() - if new_state in VALID_STATES: with state_lock: old_state = current_state @@ -109,11 +214,20 @@ class LightAPIHandler(BaseHTTPRequestHandler): self._send_json({"success": True, "state": new_state}) else: self._send_json({"error": f"Invalid state. Valid: {VALID_STATES}"}, 400) - except Exception as e: - self._send_json({"error": str(e)}, 400) - else: - self._send_json({"error": "Not found"}, 404) - + + elif self.path == '/jaw/level': + level = int(data.get('level', 0)) + level = max(0, min(100, level)) + with state_lock: + jaw_level = level + print(f"[LIGHT] Jaw level: {level}%") + self._send_json({"success": True, "jaw_level": level}) + + else: + self._send_json({"error": "Not found"}, 404) + except Exception as e: + self._send_json({"error": str(e)}, 400) + def do_OPTIONS(self): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') @@ -136,166 +250,158 @@ def signal_handler(sig, frame): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) -# === Animation Functions === -def clear_strip(): - pixels.fill((0, 0, 0)) - pixels.show() -def solid_pulse(base_color, speed, min_brightness=0.1, max_brightness=1.0): - """Return current brightness factor for a smooth pulse.""" - t = (math.sin(time.time() * speed) + 1) / 2 # 0 to 1 - return min_brightness + (max_brightness - min_brightness) * t +# === Animation Helpers === +def solid_pulse(speed, min_b=0.1, max_b=1.0): + """Return current brightness factor for smooth pulse.""" + t = (math.sin(time.time() * speed) + 1) / 2 + return min_b + (max_b - min_b) * t -def larson_scanner(base_color, position, direction, trail_length=6): - """Draw larson scanner frame, return new position and direction.""" - pixels.fill((0, 0, 0)) - - # Head - if 0 <= position < NUM_LEDS: - pixels[position] = base_color - - # Trail - for t in range(1, trail_length + 1): - fade_idx = position - direction * t - if 0 <= fade_idx < NUM_LEDS: - fade = math.cos((t / trail_length) * math.pi / 2) - pixels[fade_idx] = scale_color(base_color, fade) - - pixels.show() - - # Update position - position += direction - if position >= NUM_LEDS - 1 or position <= 0: - direction *= -1 - - return position, direction +# === Mood Animation Buffer (for complex animations) === +mood_buffer = [(0, 0, 0)] * MOOD_LEDS -def traveling_wave(base_color, offset, wavelength=20): - """Create a traveling wave effect.""" - for i in range(NUM_LEDS): - wave = (math.sin(2 * math.pi * (i - offset) / wavelength) + 1) / 2 - pixels[i] = scale_color(base_color, wave * 0.8 + 0.2) - pixels.show() +def mood_buffer_show(): + """Send entire mood buffer to Pico.""" + with serial_lock: + try: + if ser is None or not ser.is_open: + if not init_serial(): + return + for i, (r, g, b) in enumerate(mood_buffer): + ser.write(f"1 {i} {r} {g} {b}\n".encode()) + ser.readline() + ser.write("1 -1\n".encode()) + ser.readline() + except Exception as e: + print(f"[LIGHT] Buffer show error: {e}") -def sparkle(base_color, density=0.1, fade=0.9): - """Sparkle effect with random bright pixels.""" - # Fade existing - for i in range(NUM_LEDS): - current = pixels[i] - pixels[i] = scale_color(current, fade) - - # Add new sparkles - import random - for i in range(NUM_LEDS): - if random.random() < density: - pixels[i] = base_color - - pixels.show() +def mood_buffer_fill(color): + """Fill buffer with solid color.""" + for i in range(MOOD_LEDS): + mood_buffer[i] = color -# === State Colors === -STATE_COLORS = { - "idle": (0, 255, 255), # Cyan - "listening": (160, 255, 255), # Bright cyan - "responding": (100, 220, 255),# Blue-cyan - "pleasure": (180, 150, 255), # Soft purple - "thinking": (255, 180, 50), # Amber/gold - "playful": (255, 130, 90), # Warm coral - "commanding": (220, 50, 120), # Deep magenta - "love": (255, 160, 180), # Soft pink - "sleep": (20, 22, 35), # Very dim blue-gray -} +def mood_buffer_set(index, color): + """Set single buffer pixel.""" + if 0 <= index < MOOD_LEDS: + mood_buffer[index] = color # === Main Animation Loop === def main(): - global current_state, running - - print("[LIGHT] Vixy Light Service starting... 🦊") - + global current_state, jaw_level, running + + print("[LIGHT] Vixy Light Service (Pico Edition) starting... 🦊") + + if not init_serial(): + print("[LIGHT] Warning: Could not connect to Pico, will retry...") + # Start HTTP server thread http_thread = threading.Thread(target=run_http_server, daemon=True) http_thread.start() - + # Animation state larson_pos = 0 larson_dir = 1 wave_offset = 0 - - print("[LIGHT] Vixy Light Service started 🦊") - + last_jaw_level = -1 + + print("[LIGHT] Service ready 🦊") + try: while running: with state_lock: state = current_state - + jaw = jaw_level + color = STATE_COLORS.get(state, (255, 255, 255)) - + + # Update jaw if changed + if jaw != last_jaw_level: + jaw_set_level(jaw, (0, 200, 255)) # Cyan jaw + last_jaw_level = jaw + if state == "idle": - # Slow breathing pulse - brightness = solid_pulse(color, speed=0.5, min_brightness=0.2, max_brightness=0.8) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(0.5, 0.2, 0.8) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.05) - + elif state == "listening": - # Gentle pulse, brighter - brightness = solid_pulse(color, speed=2.0, min_brightness=0.6, max_brightness=1.0) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(2.0, 0.6, 1.0) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.05) - + elif state == "responding": # Traveling wave - traveling_wave(color, wave_offset, wavelength=15) + for i in range(MOOD_LEDS): + wave = (math.sin(2 * math.pi * (i - wave_offset) / 15) + 1) / 2 + mood_buffer[i] = scale_color(color, wave * 0.8 + 0.2) + mood_buffer_show() wave_offset += 0.5 time.sleep(0.03) - + + elif state == "pleasure": - # Slow sensual pulse - brightness = solid_pulse(color, speed=0.8, min_brightness=0.3, max_brightness=0.9) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(0.8, 0.3, 0.9) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.05) - + elif state == "thinking": - # Larson scanner (Knight Rider!) - larson_pos, larson_dir = larson_scanner(color, larson_pos, larson_dir, trail_length=8) + # Larson scanner + mood_buffer_fill((0, 0, 0)) + if 0 <= larson_pos < MOOD_LEDS: + mood_buffer[larson_pos] = color + # Trail + for t in range(1, 9): + idx = larson_pos - larson_dir * t + if 0 <= idx < MOOD_LEDS: + fade = math.cos((t / 8) * math.pi / 2) + mood_buffer[idx] = scale_color(color, fade) + mood_buffer_show() + larson_pos += larson_dir + if larson_pos >= MOOD_LEDS - 1 or larson_pos <= 0: + larson_dir *= -1 time.sleep(0.04) - + elif state == "playful": - # Bouncy sparkles - sparkle(color, density=0.15, fade=0.85) + # Sparkle effect + for i in range(MOOD_LEDS): + mood_buffer[i] = scale_color(mood_buffer[i], 0.85) + for i in range(MOOD_LEDS): + if random.random() < 0.15: + mood_buffer[i] = color + mood_buffer_show() time.sleep(0.05) - + elif state == "commanding": - # Strong steady pulse - brightness = solid_pulse(color, speed=3.0, min_brightness=0.5, max_brightness=1.0) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(3.0, 0.5, 1.0) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.03) - + elif state == "love": - # Gentle breathing - brightness = solid_pulse(color, speed=0.6, min_brightness=0.4, max_brightness=0.9) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(0.6, 0.4, 0.9) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.05) - + elif state == "sleep": - # Very dim, slow drift - brightness = solid_pulse(color, speed=0.2, min_brightness=0.1, max_brightness=0.3) - pixels.fill(scale_color(color, brightness)) - pixels.show() + brightness = solid_pulse(0.2, 0.1, 0.3) + c = scale_color(color, brightness) + mood_fill(*c) time.sleep(0.1) - + else: - # Unknown state - just show white - pixels.fill((50, 50, 50)) - pixels.show() + mood_fill(50, 50, 50) time.sleep(0.1) - + finally: print("[LIGHT] Cleaning up LEDs...") - clear_strip() + mood_clear() + jaw_clear() + if ser: + ser.close() print("[LIGHT] Shutdown complete") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 474e2c6..f6c1a1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1 @@ -rpi_ws281x -adafruit-circuitpython-neopixel -board +pyserial