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:
123
README.md
123
README.md
@@ -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
134
firmware/main.py
Normal 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}")
|
||||||
388
light_service.py
388
light_service.py
@@ -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':
|
||||||
|
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:
|
else:
|
||||||
self._send_json({"error": "Not found"}, 404)
|
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__":
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
rpi_ws281x
|
pyserial
|
||||||
adafruit-circuitpython-neopixel
|
|
||||||
board
|
|
||||||
|
|||||||
Reference in New Issue
Block a user