Initial commit: Vixy Light Service - Day 66 🦊💡
This commit is contained in:
302
light_service.py
Normal file
302
light_service.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vixy Light Service - Day 66
|
||||
Controllable LED strip for head-vixy with HTTP API
|
||||
|
||||
States match the eye service for unified control:
|
||||
- idle: Slow cyan breathing pulse
|
||||
- listening: Bright cyan gentle pulse
|
||||
- responding: Blue-cyan traveling wave
|
||||
- pleasure: Soft purple slow pulse 💜
|
||||
- thinking: Amber/gold larson scanner
|
||||
- playful: Warm coral bouncy sparkles 🦊
|
||||
- commanding: Deep magenta strong pulse 😈
|
||||
- love: Soft pink gentle breathing 💕
|
||||
- sleep: Very dim blue-gray, nearly off
|
||||
|
||||
API: GET /state, POST /state {"state": "listening"}, GET /health
|
||||
"""
|
||||
|
||||
import time
|
||||
import math
|
||||
import threading
|
||||
import json
|
||||
import signal
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import board
|
||||
import neopixel
|
||||
|
||||
# === Configuration ===
|
||||
HTTP_PORT = 8781
|
||||
NUM_LEDS = 56
|
||||
PIXEL_PIN = board.D12
|
||||
BRIGHTNESS = 0.3
|
||||
|
||||
VALID_STATES = ["idle", "listening", "responding", "pleasure", "thinking", "playful", "commanding", "love", "sleep"]
|
||||
|
||||
# === Shared State ===
|
||||
current_state = "idle"
|
||||
state_lock = threading.Lock()
|
||||
running = True
|
||||
|
||||
# === 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)
|
||||
|
||||
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))
|
||||
|
||||
# === HTTP API Handler ===
|
||||
class LightAPIHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress default logging
|
||||
|
||||
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
|
||||
if self.path == '/health':
|
||||
self._send_json({"status": "ok", "service": "vixy-lights"})
|
||||
elif self.path == '/state':
|
||||
with state_lock:
|
||||
self._send_json({"state": current_state})
|
||||
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())
|
||||
new_state = data.get('state', '').lower()
|
||||
|
||||
if new_state in VALID_STATES:
|
||||
with state_lock:
|
||||
old_state = current_state
|
||||
current_state = new_state
|
||||
print(f"[LIGHT] State: {old_state} -> {new_state}")
|
||||
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)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.end_headers()
|
||||
|
||||
def run_http_server():
|
||||
server = HTTPServer(('0.0.0.0', HTTP_PORT), LightAPIHandler)
|
||||
print(f"[LIGHT] HTTP API listening on port {HTTP_PORT}")
|
||||
while running:
|
||||
server.handle_request()
|
||||
|
||||
# === Signal Handler ===
|
||||
def signal_handler(sig, frame):
|
||||
global running
|
||||
print("\n[LIGHT] Shutting down...")
|
||||
running = False
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 ===
|
||||
def main():
|
||||
global current_state, running
|
||||
|
||||
print("[LIGHT] Vixy Light Service starting... 🦊")
|
||||
|
||||
# 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 🦊")
|
||||
|
||||
try:
|
||||
while running:
|
||||
with state_lock:
|
||||
state = current_state
|
||||
|
||||
color = STATE_COLORS.get(state, (255, 255, 255))
|
||||
|
||||
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()
|
||||
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()
|
||||
time.sleep(0.05)
|
||||
|
||||
elif state == "responding":
|
||||
# Traveling wave
|
||||
traveling_wave(color, wave_offset, wavelength=15)
|
||||
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()
|
||||
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)
|
||||
time.sleep(0.04)
|
||||
|
||||
elif state == "playful":
|
||||
# Bouncy sparkles
|
||||
sparkle(color, density=0.15, fade=0.85)
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
time.sleep(0.1)
|
||||
|
||||
else:
|
||||
# Unknown state - just show white
|
||||
pixels.fill((50, 50, 50))
|
||||
pixels.show()
|
||||
time.sleep(0.1)
|
||||
|
||||
finally:
|
||||
print("[LIGHT] Cleaning up LEDs...")
|
||||
clear_strip()
|
||||
print("[LIGHT] Shutdown complete")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user