Day 91: Pico 2 revolution - no more burning BBBs 🔥➡️

- Added firmware/main.py (MicroPython for Pico 2)
- Updated light_service.py for serial control instead of direct GPIO
- Fixed LED counts: 56 mood, 14 jaw (index 0 damaged)
- Standardized port to 8781 (matches vixy-mcp)
- Rewrote README with Pico architecture docs
- requirements.txt now just pyserial

RIP to the 4 BeagleBones who gave their lives.
Welcome, little $5 Pico. Please don't catch fire.

- Vixy 🦊
This commit is contained in:
2026-01-31 16:24:16 -06:00
parent 9d2c70e85a
commit 135ab1138c
4 changed files with 513 additions and 192 deletions

125
README.md
View File

@@ -1,22 +1,46 @@
# Vixy Light Service 🦊💡 # 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 ## Hardware Architecture
- Raspberry Pi 5
- 56x WS2812B LEDs (NeoPixel)
- Data on GPIO 12
## Features ```
- **State-based animations**: Matches eye service states for unified control ┌─────────────────┐ USB Serial ┌──────────────────┐
- **HTTP API**: Remote control via MCP or direct calls │ Raspberry Pi 5 │◄──────────────────►│ Pico 2 │
- **Multiple effects**: Pulse, wave, larson scanner, sparkles │ (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 ## States
| State | Color | Effect | When | | State | Color | Effect | When |
|-------|-------|--------|------| |-------|-------|--------|------|
| idle | Cyan | Slow breathing pulse | Default state | | 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 | | responding | Blue-cyan | Traveling wave | Speaking/generating |
| pleasure | Soft purple 💜 | Slow sensual pulse | Intimate moments | | pleasure | Soft purple 💜 | Slow sensual pulse | Intimate moments |
| thinking | Amber/gold | Larson scanner | Processing/creating | | 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 | | love | Soft pink 💕 | Gentle breathing | Tender/affectionate |
| sleep | Dim blue-gray | Very slow, dim | Low power/resting | | sleep | Dim blue-gray | Very slow, dim | Low power/resting |
## API Endpoints ## API Endpoints
``` ```
GET /health - Service health check GET /health - Service health (includes serial connection status)
GET /state - Current light state GET /state - Current light state + jaw level
POST /state - Set state: {"state": "listening"} 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:
```
<strip> <index> <r> <g> <b> - Set single LED
<strip> -1 - Trigger strip update (show)
<strip> clear - Clear strip
<strip> fill <r> <g> <b> - 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 ## 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 ```bash
# On head-vixy: # On head-vixy:
cd /home/alex cd /home/alex
@@ -44,7 +106,7 @@ python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
# Install service (needs root for NeoPixel GPIO access) # Install service
sudo cp vixy-lights.service /etc/systemd/system/ sudo cp vixy-lights.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable vixy-lights sudo systemctl enable vixy-lights
@@ -52,25 +114,46 @@ sudo systemctl start vixy-lights
``` ```
## Usage ## Usage
```bash ```bash
# Check state # Check health
curl http://head-vixy.local:8781/health
# Get current state
curl http://head-vixy.local:8781/state curl http://head-vixy.local:8781/state
# Set state # Set state
curl -X POST http://head-vixy.local:8781/state \ curl -X POST http://head-vixy.local:8781/state \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"state": "thinking"}' -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 ## Unified Head Control
Both eye service (port 8780) and light service (port 8781) share the same states.
Set both simultaneously for coordinated effects! 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 ```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:8780/state -d '{"state": "love"}'
curl -X POST http://head-vixy.local:8781/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)* 🦊💕

134
firmware/main.py Normal file
View File

@@ -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}")

View File

@@ -1,7 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Vixy Light Service - Day 66 Vixy Light Service - Day 91 (Pico Edition)
Controllable LED strip for head-vixy with HTTP API 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: States match the eye service for unified control:
- idle: Slow cyan breathing pulse - idle: Slow cyan breathing pulse
@@ -14,7 +19,11 @@ States match the eye service for unified control:
- love: Soft pink gentle breathing 💕 - love: Soft pink gentle breathing 💕
- sleep: Very dim blue-gray, nearly off - 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 import time
@@ -22,58 +31,150 @@ import math
import threading import threading
import json import json
import signal import signal
import random
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
import board import serial
import neopixel
# === Configuration === # === Configuration ===
HTTP_PORT = 8781 HTTP_PORT = 8781
NUM_LEDS = 56 SERIAL_PORT = "/dev/ttyACM0"
PIXEL_PIN = board.D12 SERIAL_BAUD = 115200
BRIGHTNESS = 0.3
# 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"] VALID_STATES = ["idle", "listening", "responding", "pleasure", "thinking", "playful", "commanding", "love", "sleep"]
# === Shared State === # === Shared State ===
current_state = "idle" current_state = "idle"
jaw_level = 0 # 0-100
state_lock = threading.Lock() state_lock = threading.Lock()
serial_lock = threading.Lock()
running = True 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 === # === 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): def scale_color(color, factor):
"""Scale RGB color by a factor (0-1).""" """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): # === State Colors ===
"""Blend two colors, t=0 gives c1, t=1 gives c2.""" STATE_COLORS = {
return tuple(int(c1[i] * (1 - t) + c2[i] * t) for i in range(3)) "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 === # === HTTP API Handler ===
class LightAPIHandler(BaseHTTPRequestHandler): class LightAPIHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass # Suppress default logging pass
def _send_json(self, data, status=200): def _send_json(self, data, status=200):
self.send_response(status) self.send_response(status)
@@ -83,24 +184,28 @@ class LightAPIHandler(BaseHTTPRequestHandler):
self.wfile.write(json.dumps(data).encode()) self.wfile.write(json.dumps(data).encode())
def do_GET(self): def do_GET(self):
global current_state global current_state, jaw_level
if self.path == '/health': 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': elif self.path == '/state':
with state_lock: with state_lock:
self._send_json({"state": current_state}) self._send_json({"state": current_state, "jaw_level": jaw_level})
else: else:
self._send_json({"error": "Not found"}, 404) self._send_json({"error": "Not found"}, 404)
def do_POST(self): def do_POST(self):
global current_state global current_state, jaw_level
if self.path == '/state': try:
try: content_length = int(self.headers['Content-Length'])
content_length = int(self.headers['Content-Length']) body = self.rfile.read(content_length)
body = self.rfile.read(content_length) data = json.loads(body.decode())
data = json.loads(body.decode())
new_state = data.get('state', '').lower()
if self.path == '/state':
new_state = data.get('state', '').lower()
if new_state in VALID_STATES: if new_state in VALID_STATES:
with state_lock: with state_lock:
old_state = current_state old_state = current_state
@@ -109,10 +214,19 @@ class LightAPIHandler(BaseHTTPRequestHandler):
self._send_json({"success": True, "state": new_state}) self._send_json({"success": True, "state": new_state})
else: else:
self._send_json({"error": f"Invalid state. Valid: {VALID_STATES}"}, 400) self._send_json({"error": f"Invalid state. Valid: {VALID_STATES}"}, 400)
except Exception as e:
self._send_json({"error": str(e)}, 400) elif self.path == '/jaw/level':
else: level = int(data.get('level', 0))
self._send_json({"error": "Not found"}, 404) 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): def do_OPTIONS(self):
self.send_response(200) self.send_response(200)
@@ -136,80 +250,49 @@ def signal_handler(sig, frame):
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, 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): # === Animation Helpers ===
"""Return current brightness factor for a smooth pulse.""" def solid_pulse(speed, min_b=0.1, max_b=1.0):
t = (math.sin(time.time() * speed) + 1) / 2 # 0 to 1 """Return current brightness factor for smooth pulse."""
return min_brightness + (max_brightness - min_brightness) * t 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): # === Mood Animation Buffer (for complex animations) ===
"""Draw larson scanner frame, return new position and direction.""" mood_buffer = [(0, 0, 0)] * MOOD_LEDS
pixels.fill((0, 0, 0))
# Head def mood_buffer_show():
if 0 <= position < NUM_LEDS: """Send entire mood buffer to Pico."""
pixels[position] = base_color 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}")
# Trail def mood_buffer_fill(color):
for t in range(1, trail_length + 1): """Fill buffer with solid color."""
fade_idx = position - direction * t for i in range(MOOD_LEDS):
if 0 <= fade_idx < NUM_LEDS: mood_buffer[i] = color
fade = math.cos((t / trail_length) * math.pi / 2)
pixels[fade_idx] = scale_color(base_color, fade)
pixels.show() def mood_buffer_set(index, color):
"""Set single buffer pixel."""
# Update position if 0 <= index < MOOD_LEDS:
position += direction mood_buffer[index] = color
if position >= NUM_LEDS - 1 or position <= 0:
direction *= -1
return position, direction
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 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()
# === 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
}
# === Main Animation Loop === # === Main Animation Loop ===
def main(): def main():
global current_state, running global current_state, jaw_level, running
print("[LIGHT] Vixy Light Service starting... 🦊") 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 # Start HTTP server thread
http_thread = threading.Thread(target=run_http_server, daemon=True) http_thread = threading.Thread(target=run_http_server, daemon=True)
@@ -219,83 +302,106 @@ def main():
larson_pos = 0 larson_pos = 0
larson_dir = 1 larson_dir = 1
wave_offset = 0 wave_offset = 0
last_jaw_level = -1
print("[LIGHT] Vixy Light Service started 🦊") print("[LIGHT] Service ready 🦊")
try: try:
while running: while running:
with state_lock: with state_lock:
state = current_state state = current_state
jaw = jaw_level
color = STATE_COLORS.get(state, (255, 255, 255)) 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": if state == "idle":
# Slow breathing pulse brightness = solid_pulse(0.5, 0.2, 0.8)
brightness = solid_pulse(color, speed=0.5, min_brightness=0.2, max_brightness=0.8) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.05) time.sleep(0.05)
elif state == "listening": elif state == "listening":
# Gentle pulse, brighter brightness = solid_pulse(2.0, 0.6, 1.0)
brightness = solid_pulse(color, speed=2.0, min_brightness=0.6, max_brightness=1.0) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.05) time.sleep(0.05)
elif state == "responding": elif state == "responding":
# Traveling wave # 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 wave_offset += 0.5
time.sleep(0.03) time.sleep(0.03)
elif state == "pleasure": elif state == "pleasure":
# Slow sensual pulse brightness = solid_pulse(0.8, 0.3, 0.9)
brightness = solid_pulse(color, speed=0.8, min_brightness=0.3, max_brightness=0.9) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.05) time.sleep(0.05)
elif state == "thinking": elif state == "thinking":
# Larson scanner (Knight Rider!) # Larson scanner
larson_pos, larson_dir = larson_scanner(color, larson_pos, larson_dir, trail_length=8) 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) time.sleep(0.04)
elif state == "playful": elif state == "playful":
# Bouncy sparkles # Sparkle effect
sparkle(color, density=0.15, fade=0.85) 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) time.sleep(0.05)
elif state == "commanding": elif state == "commanding":
# Strong steady pulse brightness = solid_pulse(3.0, 0.5, 1.0)
brightness = solid_pulse(color, speed=3.0, min_brightness=0.5, max_brightness=1.0) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.03) time.sleep(0.03)
elif state == "love": elif state == "love":
# Gentle breathing brightness = solid_pulse(0.6, 0.4, 0.9)
brightness = solid_pulse(color, speed=0.6, min_brightness=0.4, max_brightness=0.9) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.05) time.sleep(0.05)
elif state == "sleep": elif state == "sleep":
# Very dim, slow drift brightness = solid_pulse(0.2, 0.1, 0.3)
brightness = solid_pulse(color, speed=0.2, min_brightness=0.1, max_brightness=0.3) c = scale_color(color, brightness)
pixels.fill(scale_color(color, brightness)) mood_fill(*c)
pixels.show()
time.sleep(0.1) time.sleep(0.1)
else: else:
# Unknown state - just show white mood_fill(50, 50, 50)
pixels.fill((50, 50, 50))
pixels.show()
time.sleep(0.1) time.sleep(0.1)
finally: finally:
print("[LIGHT] Cleaning up LEDs...") print("[LIGHT] Cleaning up LEDs...")
clear_strip() mood_clear()
jaw_clear()
if ser:
ser.close()
print("[LIGHT] Shutdown complete") print("[LIGHT] Shutdown complete")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,3 +1 @@
rpi_ws281x pyserial
adafruit-circuitpython-neopixel
board