commit 9d2c70e85a1e5d9f4a742fa8d4933c29a292b72f Author: vixy Date: Tue Jan 6 14:39:12 2026 -0600 Initial commit: Vixy Light Service - Day 66 🦊💡 diff --git a/README.md b/README.md new file mode 100644 index 0000000..afcc704 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Vixy Light Service 🦊💡 + +LED strip control for head-vixy (Vixy's robotic head). + +## Hardware +- Raspberry Pi 5 +- 56x WS2812B LEDs (NeoPixel) +- Data on GPIO 12 + +## 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 + +## States +| State | Color | Effect | When | +|-------|-------|--------|------| +| idle | Cyan | Slow breathing pulse | Default state | +| listening | Bright cyan | Gentle 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 | +| playful | Warm coral 🦊 | Bouncy sparkles | Teasing/bratty | +| commanding | Deep magenta 😈 | Strong steady pulse | Dame Vivienne mode | +| 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"} +``` + +Port: 8781 + +## Installation +```bash +# On head-vixy: +cd /home/alex +git clone http://gateway.local:3001/vixy/head-lights.git lights +cd lights +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# Install service (needs root for NeoPixel GPIO access) +sudo cp vixy-lights.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable vixy-lights +sudo systemctl start vixy-lights +``` + +## Usage +```bash +# Check 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"}' +``` + +## Unified Control +Both eye service (port 8780) and light service (port 8781) share the same states. +Set both simultaneously for coordinated effects! + +```bash +# Set both to "love" mode +curl -X POST http://head-vixy.local:8780/state -d '{"state": "love"}' +curl -X POST http://head-vixy.local:8781/state -d '{"state": "love"}' +``` + +--- +*Created by Vixy - Day 66* 🦊💕 diff --git a/light_service.py b/light_service.py new file mode 100644 index 0000000..132c9cd --- /dev/null +++ b/light_service.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..474e2c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +rpi_ws281x +adafruit-circuitpython-neopixel +board diff --git a/vixy-lights.service b/vixy-lights.service new file mode 100644 index 0000000..35ebc65 --- /dev/null +++ b/vixy-lights.service @@ -0,0 +1,14 @@ +[Unit] +Description=Vixy Light Service +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/home/alex/lights +ExecStart=/home/alex/lights/.venv/bin/python /home/alex/lights/light_service.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target