Skill: thrive-spencerj/holocube-emotes (version 1.0.0)

--- name: holocube-emotes description: Control a GeekMagic holocube display as an AI emote system. Generate holographic sprite kits with Gemini, upload to device, and swap expressions based on agent state (idle, working, error, etc.). Use when the user has a GeekMagic holocube (HelloCubic-Lite or similar) and wants their AI assistant to have a physical face that reacts to conversation context. --- # Holocube Emotes Turn a GeekMagic holocube into your AI's face. Generate holographic character sprites, upload them to the device, then swap expressions in real-time based on agent/session state. ## First-Time Setup ### 0. Find the device Auto-discover holocubes on your network: ```bash python3 scripts/holocube.py --discover ``` Output: `FOUND: 192.168.0.245 — HelloCubic-Lite V7.0.22` If discovery fails, find the IP on the device's screen or your router's client list. ### 1. Generate sprites Create a full emote sprite kit (requires nano-banana-pro skill with `GEMINI_API_KEY`): ```bash python3 scripts/generate_sprites.py --output-dir ./sprites ``` Custom character: ```bash python3 scripts/generate_sprites.py --output-dir ./sprites \ --character "A glowing holographic cat floating in pure black void. Neon purple wireframe style." ``` This generates 7 emotes (neutral, happy, thinking, surprised, concerned, laughing, sleeping) as both static JPG and animated GIF, sized for the 240x240 display. ### 2. Upload to device ```bash python3 scripts/setup_device.py --sprites-dir ./sprites --clear --backup-dir ./backup ``` Flags: - `--clear` removes existing images (recommended — device has ~3MB storage) - `--backup-dir` saves existing files before clearing - `--ip` auto-discovers if not provided, or specify manually ### 3. Configure TOOLS.md Add the holocube IP and emote mappings to your workspace TOOLS.md for reference. See references/tools-example.md. ## Daily Usage ### Set emote directly ```bash python3 scripts/holocube.py happy python3 scripts/holocube.py thinking --static # Use JPG instead of GIF ``` ### Set by agent state ```bash python3 scripts/holocube.py working # → thinking python3 scripts/holocube.py complete # → happy python3 scripts/holocube.py error # → concerned python3 scripts/holocube.py opus # → thinking (heavy model) python3 scripts/holocube.py haiku # → neutral (light model) ``` ### Auto-select by time of day ```bash python3 scripts/holocube.py --auto ``` - 11pm–7am → sleeping - 7am–9am → happy - Rest of day → neutral ### Check status ```bash python3 scripts/holocube.py --status python3 scripts/holocube.py --list ``` ## Heartbeat Integration Add to HEARTBEAT.md to auto-manage the emote: ```markdown ## Holocube Emote Check - Run `python3 scripts/holocube.py --auto` to set time-appropriate emote ``` ## When to Set Emotes Use these during normal agent operations: | Context | Command | Emote | |---|---|---| | Idle, waiting for input | `neutral` | 🤖 | | Processing, running tools | `thinking` or `working` | 🔧 | | Task completed | `happy` or `complete` | 😊 | | Error occurred | `error` (→ surprised) | 😮 | | Funny moment | `laughing` or `funny` | 😂 | | Unexpected input | `surprised` or `unexpected` | 😮 | | Night/inactive | `sleeping` or `night` | 😴 | | Spawning sub-agent | `spawning` (→ thinking) | 🔧 | | On-demand custom | `custom` | ✨ | ## Custom Slot A reserved file `adam-custom.gif` on the device can be overwritten at any time for on-demand or one-off animations. Generate a GIF, upload as `adam-custom.gif`, then `python3 holocube.py custom`. Switch back to a standard emote when done. ## Device Notes - **Model:** GeekMagic HelloCubic-Lite (240x240px glass display) - **Format:** GIF (animated) or JFIF JPEG. Use Pillow for JPEG (ffmpeg lacks JFIF headers). - **Storage:** ~3MB total. 6 animated GIFs use ~1.5MB, leaving ~500KB for custom slot. - **Art style:** Dark/black backgrounds make glass disappear. Use glowing, holographic, neon elements. - **⚠️ NEVER send `/set?reset=1`** — that's factory reset, wipes WiFi config. ## Requirements - GeekMagic HelloCubic-Lite (or compatible) on local network - Python 3 with Pillow (`pip install Pillow`) - nano-banana-pro skill with `GEMINI_API_KEY` (for sprite generation only) - `uv` (`brew install uv`) (for sprite generation only)

path: references/tools-example.md
content: # TOOLS.md Example — Holocube Section

Add something like this to your workspace TOOLS.md after setup:

```markdown
### Holocube (GeekMagic HelloCubic-Lite)
- IP: http://<YOUR_IP>
- Display: 240x240px square
- Format: JFIF JPEG only (use Pillow to save, not raw ffmpeg)
- Images do NOT need to be flipped — displays right-side up
- Upload: `POST /doUpload?dir=/image` with form field `file`
- Set active: `/set?img=%2Fimage%2Ffilename.jpg`
- Set theme: `/set?theme=2` (Photo Album)
- ⚠️ NEVER use `/set?reset=1` — that's factory reset, wipes WiFi config
- Reboot: `/set?reboot=1` (response may time out)
- Art style: Glass display — dark/black backgrounds so glass disappears.

#### Emote Controller
- Script: `scripts/holocube.py`
- Usage: `python3 scripts/holocube.py <emote|state> [--static]`
- Sprites on device: `adam-{emote}.gif` (animated) and `adam-{emote}.jpg` (static)

When to set emotes:
- `neutral` — Default idle state
- `thinking` — Processing, running tools, spawning sub-agents
- `happy` — Task completed, good news
- `surprised` — Unexpected input
- `concerned` — Error occurred
- `laughing` — Funny moment
- `sleeping` — Late night (11pm-7am)
```


path: scripts/generate_sprites.py
content: #!/usr/bin/env python3
"""
Generate a holocube sprite kit using Gemini (nano-banana-pro).
Creates a base character and 7 emote variations, then converts to holocube-ready formats.

Usage:
    python3 generate_sprites.py --output-dir ./sprites [--api-key KEY] [--character DESCRIPTION]

Requires: uv, nano-banana-pro skill installed, GEMINI_API_KEY
"""

import sys
import os
import json
import argparse
import subprocess
import math
from pathlib import Path

NANO_BANANA_SCRIPT = None  # Auto-detected

EMOTES = {
    "neutral": "looking straight ahead with curious round glowing eyes. Default calm expression.",
    "happy": "looking happy and delighted. Upturned crescent-shaped glowing eyes like a big smile. Add warm glow.",
    "thinking": "looking like it is thinking deeply. One eye slightly squinted, the other looking up. Add small floating gears made of light around it.",
    "surprised": "looking surprised and shocked. Eyes wide open, big bright glowing circles. Mouth open in an O shape.",
    "laughing": "laughing hard. Eyes squeezed shut into happy curved lines like XD. Mouth wide open laughing. Head slightly tilted.",
    "sleeping": "sleeping peacefully. Eyes closed as gentle curved lines. Small Z Z Z letters floating up made of dim light. Much dimmer overall glow.",
}

DEFAULT_CHARACTER = (
    "A glowing holographic robot head floating in pure black void. "
    "Cyan and blue neon wireframe style. Luminous eyes. "
    "Ethereal digital particles dissolving around it. "
    "No background elements, just black. Hologram aesthetic, high contrast."
)


def find_nano_banana():
    """Find the nano-banana-pro generate_image.py script."""
    paths = [
        "/opt/homebrew/lib/node_modules/openclaw/skills/nano-banana-pro/scripts/generate_image.py",
        "/usr/local/lib/node_modules/openclaw/skills/nano-banana-pro/scripts/generate_image.py",
    ]
    for p in paths:
        if os.path.exists(p):
            return p
    # Try finding via npm
    try:
        result = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True)
        root = result.stdout.strip()
        p = os.path.join(root, "openclaw/skills/nano-banana-pro/scripts/generate_image.py")
        if os.path.exists(p):
            return p
    except Exception:
        pass
    return None


def get_api_key():
    """Get Gemini API key from env or OpenClaw config."""
    key = os.environ.get("GEMINI_API_KEY")
    if key:
        return key
    config_path = Path.home() / ".openclaw" / "openclaw.json"
    if config_path.exists():
        with open(config_path) as f:
            config = json.load(f)
        key = config.get("skills", {}).get("entries", {}).get("nano-banana-pro", {}).get("apiKey", "")
        if key:
            return key
    return None


def generate_base(output_dir, character_desc, api_key, script_path):
    """Generate the base character sprite."""
    base_path = output_dir / "base.png"
    prompt = f"{character_desc} The robot looks friendly and slightly mischievous. Square composition."

    print(f"Generating base character...")
    env = os.environ.copy()
    env["GEMINI_API_KEY"] = api_key

    result = subprocess.run(
        ["uv", "run", script_path, "--prompt", prompt, "--filename", str(base_path), "--resolution", "1K"],
        capture_output=True, text=True, timeout=60, env=env,
    )
    if base_path.exists():
        print(f"  ✓ Base saved: {base_path}")
        return base_path
    print(f"  ✗ Failed: {result.stderr[-200:]}")
    return None


def generate_emote(base_path, emote, desc, output_dir, character_desc, api_key, script_path):
    """Generate an emote variation from the base."""
    out_path = output_dir / f"{emote}.png"
    prompt = f"Make this robot head {desc} Keep the same wireframe holographic style on pure black background. Same character."

    env = os.environ.copy()
    env["GEMINI_API_KEY"] = api_key

    result = subprocess.run(
        ["uv", "run", script_path, "--prompt", prompt, "--filename", str(out_path), "-i", str(base_path), "--resolution", "1K"],
        capture_output=True, text=True, timeout=60, env=env,
    )
    if out_path.exists():
        print(f"  ✓ {emote}")
        return out_path
    print(f"  ✗ {emote} failed: {result.stderr[-200:]}")
    return None


def convert_to_holocube(sprites_dir):
    """Convert PNGs to holocube-ready 240x240 animated GIFs with lively motion."""
    from PIL import Image, ImageEnhance

    gif_dir = sprites_dir / "gif"
    jpg_dir = sprites_dir / "jpg"
    gif_dir.mkdir(exist_ok=True)
    jpg_dir.mkdir(exist_ok=True)

    def load(emote):
        src = sprites_dir / f"{emote}.png"
        if not src.exists():
            return None
        img = Image.open(src).convert("RGBA")
        w, h = img.size
        side = min(w, h)
        l, t = (w - side) // 2, (h - side) // 2
        return img.crop((l, t, l + side, t + side)).resize((240, 240), Image.LANCZOS)

    def xform(img, dx=0, dy=0, rot=0, scale=1.0, bright=1.0):
        f = img.copy()
        if bright != 1.0:
            f = ImageEnhance.Brightness(f).enhance(bright)
        if scale != 1.0:
            ns = int(240 * scale)
            f = f.resize((ns, ns), Image.LANCZOS)
            c = Image.new("RGBA", (240, 240), (0, 0, 0, 255))
            o = (240 - ns) // 2
            c.paste(f, (o, o), f)
            f = c
        if rot != 0:
            f = f.rotate(rot, resample=Image.BICUBIC, expand=False, fillcolor=(0, 0, 0, 255))
        if dx != 0 or dy != 0:
            c = Image.new("RGBA", (240, 240), (0, 0, 0, 255))
            c.paste(f, (int(dx), int(dy)), f)
            f = c
        rgb = Image.new("RGB", (240, 240), (0, 0, 0))
        rgb.paste(f, mask=f.split()[3])
        return rgb

    def save(frames, name, duration):
        # Also save a static JPG from frame 0
        jpg_path = jpg_dir / name.replace(".gif", ".jpg")
        frames[0].save(jpg_path, "JPEG", quality=85, subsampling=0)

        q = [f.quantize(colors=64, method=Image.Quantize.MEDIANCUT) for f in frames]
        p = gif_dir / name
        q[0].save(p, save_all=True, append_images=q[1:], duration=duration, loop=0, optimize=True)
        print(f"  {name}: {os.path.getsize(p)//1024}KB, {len(frames)}f @ {duration}ms")

    S = math.sin
    PI2 = 2 * math.pi

    # --- NEUTRAL: float + gentle blink cycle ---
    img = load("neutral")
    if img:
        frames = []
        # 20 frames: float throughout, blink at frames 14-17
        for i in range(20):
            t = S(PI2 * i / 20)
            dy = t * 6
            bright = 0.88 + 0.12 * (t * 0.5 + 0.5)
            # Blink: scale Y slightly at frames 14-16
            sc = 1.0
            if i in (14, 15, 16):
                sc = [0.97, 0.94, 0.97][i - 14]
            frames.append(xform(img, dy=dy, scale=sc, bright=bright))
        save(frames, "adam-neutral.gif", 110)

    # --- HAPPY: energetic bounce + wiggle + glow ---
    img = load("happy")
    if img:
        frames = []
        for i in range(16):
            # Double-bounce pattern
            t = abs(S(PI2 * i / 8))  # two bounces per cycle
            dy = -t * 10
            rot = S(PI2 * i / 16) * 6
            sc = 1.0 + t * 0.07
            bright = 0.9 + 0.2 * t
            frames.append(xform(img, dy=dy, rot=rot, scale=sc, bright=bright))
        save(frames, "adam-happy.gif", 75)

    # --- THINKING: slow pendulum rock + glow pulse ---
    img = load("thinking")
    if img:
        frames = []
        for i in range(24):
            # Pendulum with slight deceleration at edges
            t = S(PI2 * i / 24)
            rot = t * 8
            dx = t * 5
            dy = abs(t) * 3
            # Faster glow flicker
            bright = 0.8 + 0.2 * abs(S(PI2 * i / 6))
            frames.append(xform(img, dx=dx, dy=dy, rot=rot, bright=bright))
        save(frames, "adam-thinking.gif", 90)

    # --- SURPRISED: big jolt → bounce settle ---
    img = load("surprised")
    if img:
        frames = []
        # Keyframed: jolt up, shake, settle
        keyframes = [
            # (dx, dy, rot, scale, bright)
            (0, 0, 0, 1.0, 1.0),       # start
            (0, -12, 0, 1.12, 1.4),     # BIG jolt up
            (6, -8, 5, 1.08, 1.2),      # shake right
            (-6, -6, -5, 1.06, 1.15),   # shake left
            (4, -4, 3, 1.04, 1.1),      # settle right
            (-3, -2, -2, 1.02, 1.05),   # settle left
            (2, -1, 1, 1.01, 1.02),     # almost still
            (-1, 0, 0, 1.0, 1.0),       # rest
            (0, 0, 0, 1.0, 0.95),       # slight dim
            (0, 0, 0, 1.0, 1.0),        # back to normal
            (0, 0, 0, 1.0, 1.0),        # hold
            (0, 0, 0, 1.0, 1.0),        # hold
        ]
        for dx, dy, rot, sc, br in keyframes:
            frames.append(xform(img, dx=dx, dy=dy, rot=rot, scale=sc, bright=br))
        save(frames, "adam-surprised.gif", 70)

    # --- LAUGHING: chaotic shaking + bounce ---
    img = load("laughing")
    if img:
        frames = []
        for i in range(20):
            # Fast lateral shake (period ~4 frames)
            shake = S(PI2 * i / 2.5) * 6
            # Slower bounce
            bounce = -abs(S(PI2 * i / 10)) * 8
            rot = S(PI2 * i / 3) * 7
            sc = 1.0 + abs(S(PI2 * i / 10)) * 0.06
            bright = 0.9 + 0.2 * abs(S(PI2 * i / 5))
            frames.append(xform(img, dx=shake, dy=bounce, rot=rot, scale=sc, bright=bright))
        save(frames, "adam-laughing.gif", 60)

    # --- SLEEPING: very slow deep breathing float ---
    img = load("sleeping")
    if img:
        frames = []
        for i in range(24):
            t = S(PI2 * i / 24)
            dy = t * 8  # big slow bob
            sc = 1.0 + t * 0.03
            rot = S(PI2 * i / 48) * 2  # very slow slight tilt
            bright = 0.45 + 0.25 * (t * 0.5 + 0.5)
            frames.append(xform(img, dy=dy, rot=rot, scale=sc, bright=bright))
        save(frames, "adam-sleeping.gif", 160)


def main():
    parser = argparse.ArgumentParser(description="Generate holocube sprite kit")
    parser.add_argument("--output-dir", default="./sprites", help="Output directory")
    parser.add_argument("--api-key", help="Gemini API key (or set GEMINI_API_KEY)")
    parser.add_argument("--character", default=DEFAULT_CHARACTER, help="Character description for generation")
    parser.add_argument("--convert-only", action="store_true", help="Skip generation, just convert existing PNGs")
    args = parser.parse_args()

    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    if not args.convert_only:
        script_path = find_nano_banana()
        if not script_path:
            print("ERROR: nano-banana-pro skill not found. Install OpenClaw with nano-banana-pro.")
            sys.exit(1)

        [REDACTED_SECRET] or get_api_key()
        if not [REDACTED_SECRET]"ERROR: No GEMINI_API_KEY found. Set env var or configure in openclaw.json.")
            sys.exit(1)

        # Generate base
        base = generate_base(output_dir, args.character, api_key, script_path)
        if not base:
            sys.exit(1)

        # Copy base as neutral
        import shutil
        shutil.copy(base, output_dir / "neutral.png")

        # Generate emote variations
        print(f"\nGenerating emotes...")
        for emote, desc in EMOTES.items():
            if emote == "neutral":
                continue
            generate_emote(base, emote, desc, output_dir, args.character, api_key, script_path)

    # Convert to holocube formats
    print(f"\nConverting to holocube formats...")
    convert_to_holocube(output_dir)
    print(f"\nDone! Sprites in {output_dir}")


if __name__ == "__main__":
    main()


path: scripts/holocube.py
content: #!/usr/bin/env python3
"""
Holocube emote controller.
Push expressions to a GeekMagic holocube based on agent state.

Usage:
    python3 holocube.py <emote|state> [--static] [--ip IP]
    python3 holocube.py --list
    python3 holocube.py --status
    python3 holocube.py --auto
"""

import sys
import argparse
import urllib.request
import urllib.error
import json
from datetime import datetime

DEFAULT_IP = "192.168.0.245"

EMOTES = ["neutral", "happy", "thinking", "surprised", "laughing", "sleeping", "custom"]


def discover_holocube():
    """Scan local subnet for GeekMagic devices via /v.json."""
    import socket
    import concurrent.futures

    def check_host(ip):
        try:
            with urllib.request.urlopen(f"http://{ip}/v.json", timeout=1) as r:
                data = json.loads(r.read())
                if "m" in data and "v" in data:
                    return ip, data
        except:
            pass
        return None, None

    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(("8.8.8.8", 80))
    local_ip = s.getsockname()[0]
    s.close()
    subnet = ".".join(local_ip.split(".")[:3])

    print(f"Scanning {subnet}.0/24 for GeekMagic devices...")
    with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
        futures = {executor.submit(check_host, f"{subnet}.{i}"): i for i in range(1, 255)}
        found = []
        for future in concurrent.futures.as_completed(futures):
            ip, data = future.result()
            if ip:
                found.append((ip, data))
                print(f"  FOUND: {ip} — {data.get('m', '?')} {data.get('v', '?')}")

    if not found:
        print("No GeekMagic devices found.")
    return found

# Session/agent state → emote mapping
STATE_MAP = {
    "idle":        "neutral",
    "working":     "thinking",
    "complete":    "happy",
    "error":       "surprised",
    "concerned":   "thinking",
    "funny":       "laughing",
    "unexpected":  "surprised",
    "night":       "sleeping",
    "opus":        "thinking",
    "haiku":       "neutral",
    "spawning":    "thinking",
}


def set_emote(emote, static=False, ip=None):
    """Push an emote to the holocube."""
    ip = ip or DEFAULT_IP
    if emote in STATE_MAP:
        emote = STATE_MAP[emote]
    if emote not in EMOTES:
        print(f"ERROR: Unknown emote '{emote}'. Available: {', '.join(EMOTES)}")
        return 1

    ext = "jpg" if static else "gif"
    filename = f"adam-{emote}.{ext}"
    url = f"http://{ip}/set?img=%2Fimage%2F{filename}"

    try:
        with urllib.request.urlopen(url, timeout=5) as resp:
            body = resp.read().decode()
            if "OK" in body or resp.status == 200:
                print(f"EMOTE: {emote} ({ext})")
                return 0
            print(f"ERROR: Unexpected response: {body}")
            return 1
    except (urllib.error.URLError, Exception) as e:
        print(f"ERROR: Can't reach holocube at {ip}: {e}")
        return 1


def get_status(ip=None):
    """Check holocube connectivity and storage."""
    ip = ip or DEFAULT_IP
    try:
        with urllib.request.urlopen(f"http://{ip}/v.json", timeout=3) as r:
            info = json.loads(r.read())
        with urllib.request.urlopen(f"http://{ip}/space.json", timeout=3) as r:
            space = json.loads(r.read())
        print(f"Model: {info.get('m', 'unknown')}")
        print(f"Version: {info.get('v', 'unknown')}")
        print(f"Storage: {space.get('free', 0) // 1024}KB free / {space.get('total', 0) // 1024}KB total")
        print("Status: online")
        return 0
    except Exception as e:
        print(f"Status: offline ({e})")
        return 1


def auto_emote(ip=None):
    """Pick emote based on time of day."""
    hour = datetime.now().hour
    if hour >= 23 or hour < 7:
        return set_emote("sleeping", ip=ip)
    elif 7 <= hour < 9:
        return set_emote("happy", ip=ip)
    else:
        return set_emote("neutral", ip=ip)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Holocube emote controller")
    parser.add_argument("emote", nargs="?", help="Emote or state name")
    parser.add_argument("--static", action="store_true", help="Use static JPG instead of GIF")
    parser.add_argument("--ip", default=DEFAULT_IP, help="Holocube IP address")
    parser.add_argument("--list", action="store_true", help="List available emotes and states")
    parser.add_argument("--status", action="store_true", help="Check holocube status")
    parser.add_argument("--auto", action="store_true", help="Auto-select emote based on time")
    parser.add_argument("--discover", action="store_true", help="Scan network for holocube devices")
    args = parser.parse_args()

    if args.discover:
        found = discover_holocube()
        sys.exit(0 if found else 1)
    elif args.list:
        print("Emotes:", ", ".join(EMOTES))
        print("\nState mappings:")
        for state, emote in STATE_MAP.items():
            print(f"  {state:15s} → {emote}")
        sys.exit(0)
    elif args.status:
        sys.exit(get_status(args.ip))
    elif args.auto:
        sys.exit(auto_emote(args.ip))
    elif args.emote:
        sys.exit(set_emote(args.emote, args.static, args.ip))
    else:
        parser.print_help()
        sys.exit(1)


path: scripts/onboard.py
content: #!/usr/bin/env python3
"""
Interactive onboarding for holocube-emotes.
Guides through device discovery, character creation, and setup.
"""

import sys
import os
import json
import time
import urllib.request
import concurrent.futures
import socket
from pathlib import Path


def print_banner():
    print("""
╔══════════════════════════════════════╗
║     🤖 Holocube Emotes Setup 🤖     ║
║  Give your AI a face on the cube!   ║
╚══════════════════════════════════════╝
""")


def discover_devices():
    """Scan local subnet for GeekMagic devices."""
    def check(ip):
        try:
            with urllib.request.urlopen(f"http://{ip}/v.json", timeout=1) as r:
                data = json.loads(r.read())
                if "m" in data:
                    return ip, data
        except:
            pass
        return None, None

    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(("8.8.8.8", 80))
    local_ip = s.getsockname()[0]
    s.close()
    subnet = ".".join(local_ip.split(".")[:3])

    print(f"Scanning {subnet}.0/24 for holocube devices...")
    with concurrent.futures.ThreadPoolExecutor(max_workers=50) as ex:
        futures = {ex.submit(check, f"{subnet}.{i}"): i for i in range(1, 255)}
        found = []
        for f in concurrent.futures.as_completed(futures):
            ip, data = f.result()
            if ip:
                found.append((ip, data))
    return found


CHARACTER_PRESETS = {
    "robot": {
        "name": "Holographic Robot",
        "desc": "A glowing holographic robot head floating in pure black void. Cyan and blue neon wireframe style. Luminous eyes. Ethereal digital particles dissolving around it. No background elements, just black. Hologram aesthetic, high contrast.",
    },
    "cat": {
        "name": "Neon Cat",
        "desc": "A glowing holographic cat face floating in pure black void. Purple and magenta neon wireframe style. Glowing eyes with slit pupils. Whiskers made of light. No background elements, just black. Hologram aesthetic, high contrast.",
    },
    "skull": {
        "name": "Cyber Skull",
        "desc": "A glowing holographic skull floating in pure black void. Green and teal neon wireframe style. Glowing eye sockets with flickering flames. No background elements, just black. Hologram aesthetic, high contrast.",
    },
    "alien": {
        "name": "Alien Visitor",
        "desc": "A glowing holographic alien head floating in pure black void. Green and cyan bioluminescent style. Large oval eyes that glow. Smooth elongated head. No background elements, just black. Hologram aesthetic, high contrast.",
    },
    "ghost": {
        "name": "Friendly Ghost",
        "desc": "A glowing holographic friendly ghost floating in pure black void. White and pale blue ethereal glow. Round cute eyes. Wispy trailing bottom. No background elements, just black. Hologram aesthetic, high contrast.",
    },
}


def pick_character():
    """Let user choose or describe a character."""
    print("\n🎨 Choose your character:\n")
    presets = list(CHARACTER_PRESETS.items())
    for i, (key, preset) in enumerate(presets, 1):
        print(f"  {i}. {preset['name']}")
    print(f"  {len(presets) + 1}. Custom (describe your own)")

    while True:
        choice = input(f"\nPick [1-{len(presets) + 1}]: ").strip()
        try:
            idx = int(choice)
            if 1 <= idx <= len(presets):
                key, preset = presets[idx - 1]
                print(f"\n✓ {preset['name']} selected!")
                return preset["desc"], preset["name"]
            elif idx == len(presets) + 1:
                print("\nDescribe your character (holographic style on black background):")
                print("Example: 'A glowing dragon head with fire eyes'")
                desc = input("> ").strip()
                if desc:
                    # Append holocube style guidance
                    full_desc = (
                        f"A glowing holographic {desc} floating in pure black void. "
                        f"Neon wireframe style. No background elements, just black. "
                        f"Hologram aesthetic, high contrast."
                    )
                    name = input("Give it a name: ").strip() or "Custom"
                    return full_desc, name
        except ValueError:
            pass
        print("Invalid choice, try again.")


def generate_sprites(character_desc, output_dir, api_key):
    """Generate all emote sprites."""
    from generate_sprites import EMOTES, find_nano_banana, generate_base, generate_emote
    import shutil

    script = find_nano_banana()
    if not script:
        print("ERROR: nano-banana-pro skill not found.")
        return False

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    print("\n⏳ Generating base character (this takes ~15 seconds)...")
    base = generate_base(output_dir, character_desc, api_key, script)
    if not base:
        return False

    shutil.copy(base, output_dir / "neutral.png")

    # Only generate the 5 non-neutral emotes
    emotes_to_gen = {k: v for k, v in EMOTES.items() if k != "neutral"}
    total = len(emotes_to_gen)

    print(f"\n⏳ Generating {total} emote variations (~15 sec each)...")
    for i, (emote, desc) in enumerate(emotes_to_gen.items(), 1):
        print(f"  [{i}/{total}] {emote}...")
        generate_emote(base, emote, desc, output_dir, character_desc, api_key, script)

    return True


def convert_and_upload(sprites_dir, ip):
    """Convert sprites to animated GIFs and upload to device."""
    from generate_sprites import convert_to_holocube

    sprites_dir = Path(sprites_dir)
    print("\n🎬 Creating animations...")
    convert_to_holocube(sprites_dir)

    gif_dir = sprites_dir / "gif"

    print(f"\n📡 Uploading to holocube at {ip}...")

    # Clear device
    urllib.request.urlopen(f"http://{ip}/set?clear=image", timeout=5)
    print("  Cleared old images")
    time.sleep(1)

    # Upload GIFs
    import http.client
    for f in sorted(gif_dir.iterdir()):
        if not f.suffix == ".gif":
            continue
        with open(f, "rb") as fh:
            data = fh.read()
        boundary = "----HolocubeUpload"
        body = (
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="file"; filename="{f.name}"\r\n'
            f"Content-Type: image/gif\r\n\r\n"
        ).encode() + data + f"\r\n--{boundary}--\r\n".encode()
        conn = http.client.HTTPConnection(ip, timeout=10)
        conn.request("POST", "/doUpload?dir=/image", body,
                      {"Content-Type": f"multipart/form-data; boundary={boundary}"})
        resp = conn.getresponse()
        conn.close()
        size = len(data) // 1024
        print(f"  {'✓' if resp.status == 200 else '✗'} {f.name} ({size}KB)")

    # Set theme and default
    urllib.request.urlopen(f"http://{ip}/set?theme=2", timeout=3)
    time.sleep(1)
    urllib.request.urlopen(f"http://{ip}/set?img=%2Fimage%2Fadam-neutral.gif", timeout=3)
    print("\n  Set to Photo Album, default: neutral")

    with urllib.request.urlopen(f"http://{ip}/space.json", timeout=3) as r:
        space = json.loads(r.read())
    print(f"  Storage: {space.get('free', 0) // 1024}KB free")


def get_api_key():
    key = os.environ.get("GEMINI_API_KEY")
    if key:
        return key
    config_path = Path.home() / ".openclaw" / "openclaw.json"
    if config_path.exists():
        with open(config_path) as f:
            config = json.load(f)
        key = config.get("skills", {}).get("entries", {}).get("nano-banana-pro", {}).get("apiKey", "")
        if key:
            return key
    return None


def main():
    print_banner()

    # Step 1: Find device
    print("Step 1: Find your holocube\n")
    found = discover_devices()

    if found:
        if len(found) == 1:
            ip = found[0][0]
            info = found[0][1]
            print(f"  Found: {info.get('m', '?')} at {ip}")
            confirm = input("  Use this device? [Y/n]: ").strip().lower()
            if confirm == "n":
                ip = input("  Enter IP manually: ").strip()
        else:
            print("  Multiple devices found:")
            for i, (dev_ip, info) in enumerate(found, 1):
                print(f"    {i}. {info.get('m', '?')} at {dev_ip}")
            choice = input(f"  Pick [1-{len(found)}]: ").strip()
            ip = found[int(choice) - 1][0]
    else:
        print("  No devices found automatically.")
        ip = input("  Enter holocube IP address: ").strip()

    print(f"\n✓ Using device at {ip}")

    # Step 2: Pick character
    print("\nStep 2: Design your character")
    character_desc, char_name = pick_character()

    # Step 3: Check API key
    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"\n⚠️  No Gemini API key found.")
        [REDACTED_SECRET]"Enter your GEMINI_API_KEY: ").strip()
        if not [REDACTED_SECRET]"Cannot generate sprites without an API key.")
            sys.exit(1)

    # Step 4: Generate
    print(f"\nStep 3: Generating '{char_name}' sprite kit")
    print("This will take 1-2 minutes...\n")

    sprites_dir = Path.home() / ".openclaw" / "workspace" / "assets" / "holocube-sprites"
    if not generate_sprites(character_desc, sprites_dir, api_key):
        print("Sprite generation failed.")
        sys.exit(1)

    # Step 5: Convert and upload
    print("\nStep 4: Animating and uploading")
    convert_and_upload(sprites_dir, ip)

    # Done
    print(f"""
╔══════════════════════════════════════╗
║          ✅ Setup Complete!          ║
╠══════════════════════════════════════╣
║  Your holocube now has a face!      ║
║                                     ║
║  Control it:                        ║
║    python3 holocube.py happy        ║
║    python3 holocube.py thinking     ║
║    python3 holocube.py --auto       ║
║    python3 holocube.py --discover   ║
║                                     ║
║  Device: {ip:>25s}  ║
║  Character: {char_name:>22s}  ║
╚══════════════════════════════════════╝
""")


if __name__ == "__main__":
    main()


path: scripts/setup_device.py
content: #!/usr/bin/env python3
"""
Upload sprite kit to a GeekMagic holocube and configure it.

Usage:
    python3 setup_device.py --sprites-dir ./sprites [--ip IP] [--clear] [--backup-dir DIR]
"""

import sys
import os
import argparse
import urllib.request
import urllib.error
import json
from pathlib import Path


def check_device(ip):
    """Verify holocube is reachable."""
    try:
        with urllib.request.urlopen(f"http://{ip}/v.json", timeout=3) as r:
            info = json.loads(r.read())
        with urllib.request.urlopen(f"http://{ip}/space.json", timeout=3) as r:
            space = json.loads(r.read())
        print(f"Device: {info.get('m', 'unknown')} {info.get('v', '')}")
        print(f"Storage: {space.get('free', 0) // 1024}KB free / {space.get('total', 0) // 1024}KB total")
        return True
    except Exception as e:
        print(f"ERROR: Can't reach device at {ip}: {e}")
        return False


def backup_device(ip, backup_dir):
    """Download all existing images from the device."""
    backup_dir = Path(backup_dir)
    backup_dir.mkdir(parents=True, exist_ok=True)

    try:
        with urllib.request.urlopen(f"http://{ip}/filelist?dir=/image", timeout=5) as r:
            html = r.read().decode()
    except Exception as e:
        print(f"ERROR: Can't list files: {e}")
        return False

    # Parse filenames from HTML
    import re
    files = re.findall(r"href='/image/([^']+)'", html)

    if not files:
        print("No files to back up.")
        return True

    print(f"Backing up {len(files)} files...")
    for f in files:
        try:
            urllib.request.urlretrieve(f"http://{ip}/image/{f}", str(backup_dir / f))
            print(f"  ✓ {f}")
        except Exception as e:
            print(f"  ✗ {f}: {e}")
    return True


def clear_device(ip):
    """Clear all images from the device."""
    try:
        with urllib.request.urlopen(f"http://{ip}/set?clear=image", timeout=5) as r:
            body = r.read().decode()
        if "OK" in body:
            print("Cleared all images from device.")
            return True
        print(f"Unexpected response: {body}")
        return False
    except Exception as e:
        print(f"ERROR: {e}")
        return False


def upload_file(ip, filepath, filename):
    """Upload a single file to the holocube."""
    import http.client
    import mimetypes

    with open(filepath, "rb") as f:
        file_data = f.read()

    boundary = "----HolocubeUpload"
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: {mimetypes.guess_type(filename)[0] or 'application/octet-stream'}\r\n"
        f"\r\n"
    ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()

    try:
        conn = http.client.HTTPConnection(ip, timeout=10)
        conn.request(
            "POST", "/doUpload?dir=/image", body,
            {"Content-Type": f"multipart/form-data; boundary={boundary}"},
        )
        resp = conn.getresponse()
        conn.close()
        return resp.status == 200
    except Exception as e:
        print(f"  ✗ Upload error: {e}")
        return False


def upload_sprites(ip, sprites_dir):
    """Upload all sprites (JPG and GIF) to the device."""
    sprites_dir = Path(sprites_dir)
    jpg_dir = sprites_dir / "jpg"
    gif_dir = sprites_dir / "gif"

    files_to_upload = []
    for d in [jpg_dir, gif_dir]:
        if d.exists():
            for f in sorted(d.iterdir()):
                if f.suffix in (".jpg", ".gif"):
                    files_to_upload.append(f)

    if not files_to_upload:
        print("ERROR: No sprite files found.")
        return False

    print(f"Uploading {len(files_to_upload)} sprites...")
    for f in files_to_upload:
        ok = upload_file(ip, str(f), f.name)
        size = os.path.getsize(f) // 1024
        print(f"  {'✓' if ok else '✗'} {f.name} ({size}KB)")

    return True


def configure_device(ip):
    """Set theme to Photo Album and set neutral as default."""
    try:
        urllib.request.urlopen(f"http://{ip}/set?theme=2", timeout=3)
        import time; time.sleep(1)
        urllib.request.urlopen(f"http://{ip}/set?img=%2Fimage%2Fadam-neutral.gif", timeout=3)
        print("Set theme to Photo Album, default emote: neutral (animated)")
        return True
    except Exception as e:
        print(f"ERROR: {e}")
        return False


def main():
    parser = argparse.ArgumentParser(description="Upload sprites to holocube")
    parser.add_argument("--sprites-dir", default="./sprites", help="Sprites directory")
    parser.add_argument("--ip", default=None, help="Holocube IP (auto-discovers if not provided)")
    parser.add_argument("--clear", action="store_true", help="Clear existing images first")
    parser.add_argument("--backup-dir", help="Back up existing images before clearing")
    args = parser.parse_args()

    ip = args.ip
    if not ip:
        # Auto-discover
        print("No IP provided, scanning network...")
        from holocube import discover_holocube
        found = discover_holocube()
        if found:
            ip = found[0][0]
            print(f"Using discovered device: {ip}")
        else:
            print("ERROR: No device found. Provide --ip manually.")
            sys.exit(1)

    print(f"Connecting to holocube at {ip}...")
    if not check_device(ip):
        sys.exit(1)

    if args.backup_dir:
        backup_device(ip, args.backup_dir)

    if args.clear:
        clear_device(ip)

    upload_sprites(ip, args.sprites_dir)
    configure_device(ip)

    print("\n✓ Setup complete!")
    with urllib.request.urlopen(f"http://{ip}/space.json", timeout=3) as r:
        space = json.loads(r.read())
    print(f"Storage remaining: {space.get('free', 0) // 1024}KB free")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)