#!/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()