eye service
This commit is contained in:
415
eye_service_mk2.py
Normal file
415
eye_service_mk2.py
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Vixy Eye Service Mk2 - USB Bridge
|
||||||
|
HTTP API (port 8780) → USB serial → ESP32 Co5300 eyes
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
S:idle\\n → set state
|
||||||
|
G:127:127\\n → set gaze x:y (0-255)
|
||||||
|
T:1234567\\n → sync animation clock
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 eye_service_mk2.py
|
||||||
|
python3 eye_service_mk2.py --learn # record current USB serials as left/right
|
||||||
|
python3 eye_service_mk2.py --left /dev/tty.usbmodem1 --right /dev/tty.usbmodem2
|
||||||
|
"""
|
||||||
|
|
||||||
|
import serial, serial.tools.list_ports
|
||||||
|
import threading, time, argparse, random, os, json, math, signal, sys
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
HTTP_PORT = 8780
|
||||||
|
BAUD = 115200
|
||||||
|
SYNC_INTERVAL = 10.0 # seconds between T: sync commands
|
||||||
|
RECONNECT_INTERVAL = 2.0 # seconds between reconnect attempts
|
||||||
|
SACCADE_MIN_INTERVAL = 15.0
|
||||||
|
SACCADE_MAX_INTERVAL = 30.0
|
||||||
|
SACCADE_DURATION = 0.2
|
||||||
|
|
||||||
|
CONFIG_DIR = os.path.expanduser("~/.vixy")
|
||||||
|
CONFIG_PATH = os.path.join(CONFIG_DIR, "eyes.json")
|
||||||
|
|
||||||
|
VALID_STATES = [
|
||||||
|
"idle","listening","responding","pleasure",
|
||||||
|
"thinking","playful","commanding","love","sleep"
|
||||||
|
]
|
||||||
|
|
||||||
|
# === Shared state ===
|
||||||
|
current_state = "idle"
|
||||||
|
baseline_gaze = [127, 127] # user-commanded gaze (center = 127)
|
||||||
|
state_lock = threading.Lock()
|
||||||
|
running = True
|
||||||
|
|
||||||
|
|
||||||
|
class EyePort:
|
||||||
|
"""One eye's serial connection, identified by USB serial number for stability."""
|
||||||
|
|
||||||
|
def __init__(self, label: str, usb_serial: str = None, device: str = None):
|
||||||
|
self.label = label # "left" or "right"
|
||||||
|
self.usb_serial = usb_serial # stable USB serial number (preferred)
|
||||||
|
self.device = device # /dev path (fallback, unstable)
|
||||||
|
self.ser = None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def resolve_device(self) -> str | None:
|
||||||
|
"""Find the /dev path for this eye. Prefer USB serial match; fall back to device path."""
|
||||||
|
if self.usb_serial:
|
||||||
|
for p in serial.tools.list_ports.comports():
|
||||||
|
if p.serial_number and p.serial_number == self.usb_serial:
|
||||||
|
return p.device
|
||||||
|
return None
|
||||||
|
return self.device
|
||||||
|
|
||||||
|
def open(self) -> bool:
|
||||||
|
path = self.resolve_device()
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self.lock:
|
||||||
|
self.ser = serial.Serial(path, BAUD, timeout=1, write_timeout=1)
|
||||||
|
time.sleep(2) # ESP32 USB CDC enumeration
|
||||||
|
print(f"[EYE] {self.label}: opened {path}"
|
||||||
|
+ (f" (USB serial {self.usb_serial})" if self.usb_serial else ""))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EYE] {self.label}: cannot open {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.ser:
|
||||||
|
try:
|
||||||
|
self.ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.ser = None
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
if not self.ser or not self.ser.is_open:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.ser.write(data)
|
||||||
|
self.ser.flush()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EYE] {self.label}: write error: {e}")
|
||||||
|
try:
|
||||||
|
self.ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.ser = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
return bool(self.ser and self.ser.is_open)
|
||||||
|
|
||||||
|
|
||||||
|
eyes: list[EyePort] = [] # populated in main()
|
||||||
|
|
||||||
|
|
||||||
|
def send_cmd(cmd: str):
|
||||||
|
"""Send a command to all eyes. Holds each port's lock independently."""
|
||||||
|
data = (cmd + "\n").encode()
|
||||||
|
for eye in eyes:
|
||||||
|
eye.write(data)
|
||||||
|
|
||||||
|
def send_state(state: str): send_cmd(f"S:{state}")
|
||||||
|
def send_gaze(x: int, y: int): send_cmd(f"G:{x}:{y}")
|
||||||
|
def send_sync():
|
||||||
|
ms = int(time.time() * 1000) % (2**31)
|
||||||
|
send_cmd(f"T:{ms}")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_loop():
|
||||||
|
"""Periodically resync animation clocks."""
|
||||||
|
while running:
|
||||||
|
time.sleep(SYNC_INTERVAL)
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
send_sync()
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_loop():
|
||||||
|
"""Reopen any eye that's currently disconnected."""
|
||||||
|
while running:
|
||||||
|
time.sleep(RECONNECT_INTERVAL)
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
for eye in eyes:
|
||||||
|
if not eye.is_connected():
|
||||||
|
if eye.open():
|
||||||
|
# Restore full state on the reconnected eye
|
||||||
|
with state_lock:
|
||||||
|
state = current_state
|
||||||
|
gx, gy = baseline_gaze
|
||||||
|
send_sync() # cheap, safe to send to both
|
||||||
|
send_state(state)
|
||||||
|
send_gaze(gx, gy)
|
||||||
|
|
||||||
|
|
||||||
|
def saccade_loop():
|
||||||
|
"""Random micro-flicks to keep the eyes feeling alive. Returns to baseline after each."""
|
||||||
|
while running:
|
||||||
|
interval = random.uniform(SACCADE_MIN_INTERVAL, SACCADE_MAX_INTERVAL)
|
||||||
|
# Sleep in small chunks so shutdown is responsive
|
||||||
|
slept = 0.0
|
||||||
|
while slept < interval and running:
|
||||||
|
time.sleep(0.5)
|
||||||
|
slept += 0.5
|
||||||
|
if not running:
|
||||||
|
break
|
||||||
|
|
||||||
|
dx = random.randint(-20, 20)
|
||||||
|
dy = random.randint(-10, 10)
|
||||||
|
with state_lock:
|
||||||
|
gx, gy = baseline_gaze
|
||||||
|
fx = max(0, min(255, gx + dx))
|
||||||
|
fy = max(0, min(255, gy + dy))
|
||||||
|
send_gaze(fx, fy)
|
||||||
|
time.sleep(SACCADE_DURATION)
|
||||||
|
# Return to baseline (re-read in case it changed mid-saccade)
|
||||||
|
with state_lock:
|
||||||
|
gx, gy = baseline_gaze
|
||||||
|
send_gaze(gx, gy)
|
||||||
|
|
||||||
|
|
||||||
|
def doa_to_gaze(doa_angle: float) -> tuple[int, int]:
|
||||||
|
"""Convert DoA angle (0-360) to gaze x:y (0-255)."""
|
||||||
|
rad = math.radians(doa_angle)
|
||||||
|
x = int(127 - 80 * math.sin(rad))
|
||||||
|
y = int(127 - 40 * math.cos(rad))
|
||||||
|
return max(0, min(255, x)), max(0, min(255, y))
|
||||||
|
|
||||||
|
|
||||||
|
# === HTTP API ===
|
||||||
|
class EyeAPIHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args): pass
|
||||||
|
|
||||||
|
def _json(self, data, status=200):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
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 do_GET(self):
|
||||||
|
if self.path == "/health":
|
||||||
|
self._json({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "vixy-eyes-mk2",
|
||||||
|
"eyes": {e.label: e.is_connected() for e in eyes},
|
||||||
|
})
|
||||||
|
elif self.path == "/state":
|
||||||
|
with state_lock:
|
||||||
|
self._json({"state": current_state, "gaze": list(baseline_gaze)})
|
||||||
|
else:
|
||||||
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
global current_state
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
data = json.loads(self.rfile.read(length).decode())
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.path == "/state":
|
||||||
|
new_state = data.get("state", "").lower()
|
||||||
|
if new_state in VALID_STATES:
|
||||||
|
with state_lock:
|
||||||
|
old, current_state = current_state, new_state
|
||||||
|
save_persistent_state()
|
||||||
|
print(f"[EYE] {old} → {new_state}")
|
||||||
|
send_state(new_state)
|
||||||
|
self._json({"ok": True, "state": new_state})
|
||||||
|
else:
|
||||||
|
self._json({"error": f"invalid state. valid: {VALID_STATES}"}, 400)
|
||||||
|
|
||||||
|
elif self.path == "/gaze":
|
||||||
|
if "doa" in data:
|
||||||
|
x, y = doa_to_gaze(float(data["doa"]))
|
||||||
|
else:
|
||||||
|
x = max(0, min(255, int(data.get("x", 127))))
|
||||||
|
y = max(0, min(255, int(data.get("y", 127))))
|
||||||
|
with state_lock:
|
||||||
|
baseline_gaze[0], baseline_gaze[1] = x, y
|
||||||
|
save_persistent_state()
|
||||||
|
send_gaze(x, y)
|
||||||
|
self._json({"ok": True, "gaze": [x, y]})
|
||||||
|
else:
|
||||||
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
|
||||||
|
# === Port identification ===
|
||||||
|
def list_candidate_ports():
|
||||||
|
"""Return serial ports that look like ESP32 USB CDC."""
|
||||||
|
candidates = []
|
||||||
|
for p in serial.tools.list_ports.comports():
|
||||||
|
d = (p.description or "").lower()
|
||||||
|
if (any(x in d for x in ["cp210", "ch340", "esp32", "usb serial"])
|
||||||
|
or "usbmodem" in p.device
|
||||||
|
or "ttyacm" in p.device.lower()):
|
||||||
|
candidates.append(p)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def load_eye_config() -> dict:
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EYE] failed to read {CONFIG_PATH}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_eye_config(cfg: dict):
|
||||||
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def learn_eyes():
|
||||||
|
"""Record the currently-connected ESP32 USB serial numbers as left/right."""
|
||||||
|
candidates = list_candidate_ports()
|
||||||
|
if len(candidates) < 2:
|
||||||
|
print(f"[EYE] learn: need 2 candidate ports, found {len(candidates)}")
|
||||||
|
return False
|
||||||
|
# Deterministic assignment: sort by device path so "learn" is repeatable
|
||||||
|
candidates.sort(key=lambda p: p.device)
|
||||||
|
cfg = load_eye_config()
|
||||||
|
cfg.setdefault("eyes", {})
|
||||||
|
cfg["eyes"]["left"] = {"usb_serial": candidates[0].serial_number, "device": candidates[0].device}
|
||||||
|
cfg["eyes"]["right"] = {"usb_serial": candidates[1].serial_number, "device": candidates[1].device}
|
||||||
|
save_eye_config(cfg)
|
||||||
|
print(f"[EYE] learned:")
|
||||||
|
print(f" left = {candidates[0].device} (USB serial {candidates[0].serial_number})")
|
||||||
|
print(f" right = {candidates[1].device} (USB serial {candidates[1].serial_number})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# === Persistent state ===
|
||||||
|
def save_persistent_state():
|
||||||
|
"""Save current_state + baseline_gaze so restarts restore mood/gaze."""
|
||||||
|
cfg = load_eye_config()
|
||||||
|
with state_lock:
|
||||||
|
cfg["last_state"] = current_state
|
||||||
|
cfg["last_gaze"] = list(baseline_gaze)
|
||||||
|
try:
|
||||||
|
save_eye_config(cfg)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[EYE] failed to save state: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_persistent_state():
|
||||||
|
global current_state
|
||||||
|
cfg = load_eye_config()
|
||||||
|
last_state = cfg.get("last_state")
|
||||||
|
last_gaze = cfg.get("last_gaze")
|
||||||
|
if last_state in VALID_STATES:
|
||||||
|
current_state = last_state
|
||||||
|
if isinstance(last_gaze, list) and len(last_gaze) == 2:
|
||||||
|
baseline_gaze[0], baseline_gaze[1] = int(last_gaze[0]), int(last_gaze[1])
|
||||||
|
|
||||||
|
|
||||||
|
# === Main ===
|
||||||
|
def main():
|
||||||
|
global running
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--left", default=None,
|
||||||
|
help="Override left eye device path (disables USB serial matching)")
|
||||||
|
parser.add_argument("--right", default=None,
|
||||||
|
help="Override right eye device path")
|
||||||
|
parser.add_argument("--port", type=int, default=HTTP_PORT)
|
||||||
|
parser.add_argument("--learn", action="store_true",
|
||||||
|
help="Record currently-connected ESP32 USB serials as left/right and exit")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.learn:
|
||||||
|
sys.exit(0 if learn_eyes() else 1)
|
||||||
|
|
||||||
|
load_persistent_state()
|
||||||
|
|
||||||
|
# Build EyePort objects — prefer CLI overrides, then learned USB serials, then positional auto-detect
|
||||||
|
cfg = load_eye_config().get("eyes", {})
|
||||||
|
|
||||||
|
if args.left and args.right:
|
||||||
|
eyes.append(EyePort("left", device=args.left))
|
||||||
|
eyes.append(EyePort("right", device=args.right))
|
||||||
|
elif cfg.get("left") and cfg.get("right"):
|
||||||
|
eyes.append(EyePort("left", usb_serial=cfg["left"].get("usb_serial"),
|
||||||
|
device=cfg["left"].get("device")))
|
||||||
|
eyes.append(EyePort("right", usb_serial=cfg["right"].get("usb_serial"),
|
||||||
|
device=cfg["right"].get("device")))
|
||||||
|
else:
|
||||||
|
detected = list_candidate_ports()
|
||||||
|
detected.sort(key=lambda p: p.device)
|
||||||
|
print(f"[EYE] no learned config; detected ports: {[p.device for p in detected]}")
|
||||||
|
print(f"[EYE] run with --learn to pin left/right to USB serial numbers")
|
||||||
|
if len(detected) >= 2:
|
||||||
|
eyes.append(EyePort("left", device=detected[0].device))
|
||||||
|
eyes.append(EyePort("right", device=detected[1].device))
|
||||||
|
elif len(detected) == 1:
|
||||||
|
eyes.append(EyePort("left", device=detected[0].device))
|
||||||
|
else:
|
||||||
|
print("[EYE] no ESP32 ports found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Open what we can; reconnect loop will retry anything that fails
|
||||||
|
for eye in eyes:
|
||||||
|
eye.open()
|
||||||
|
|
||||||
|
# Restore state on connected eyes
|
||||||
|
send_sync()
|
||||||
|
with state_lock:
|
||||||
|
state = current_state
|
||||||
|
gx, gy = baseline_gaze
|
||||||
|
send_state(state)
|
||||||
|
send_gaze(gx, gy)
|
||||||
|
|
||||||
|
# Background threads
|
||||||
|
threading.Thread(target=sync_loop, daemon=True).start()
|
||||||
|
threading.Thread(target=reconnect_loop, daemon=True).start()
|
||||||
|
threading.Thread(target=saccade_loop, daemon=True).start()
|
||||||
|
|
||||||
|
# Signal handlers for clean shutdown under systemd/k8s
|
||||||
|
def handle_shutdown(sig, frame):
|
||||||
|
global running
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
print(f"\n[EYE] signal {sig} received, shutting down")
|
||||||
|
running = False
|
||||||
|
try:
|
||||||
|
server.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
signal.signal(signal.SIGINT, handle_shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, handle_shutdown)
|
||||||
|
|
||||||
|
server = HTTPServer(("0.0.0.0", args.port), EyeAPIHandler)
|
||||||
|
print(f"[EYE] Vixy Eye Mk2 on port {args.port}")
|
||||||
|
print(f"[EYE] connected: {[e.label for e in eyes if e.is_connected()]}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
finally:
|
||||||
|
running = False
|
||||||
|
for eye in eyes:
|
||||||
|
eye.close()
|
||||||
|
print("[EYE] shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
76
install.sh
Executable file
76
install.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Vixy Eye Service Mk2 - Installer
|
||||||
|
# Deploys to ~/eyes-mk2, creates venv, installs systemd service
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./install.sh # install and start service
|
||||||
|
# ./install.sh --learn # also run --learn to pin left/right USB serials
|
||||||
|
|
||||||
|
INSTALL_DIR="$HOME/eyes-mk2"
|
||||||
|
VENV_DIR="$INSTALL_DIR/.venv"
|
||||||
|
SERVICE_NAME="vixy-eyes-mk2"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "[install] Vixy Eye Service Mk2"
|
||||||
|
|
||||||
|
# --- Stop existing service if running ---
|
||||||
|
if systemctl --user is-active "$SERVICE_NAME" &>/dev/null; then
|
||||||
|
echo "[install] stopping existing $SERVICE_NAME service"
|
||||||
|
systemctl --user stop "$SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Copy files ---
|
||||||
|
echo "[install] deploying to $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cp "$SCRIPT_DIR/eye_service_mk2.py" "$INSTALL_DIR/"
|
||||||
|
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
# --- Copy firmware for reference ---
|
||||||
|
if [ -d "$SCRIPT_DIR/vixy_eye" ]; then
|
||||||
|
cp -r "$SCRIPT_DIR/vixy_eye" "$INSTALL_DIR/firmware"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Create/update venv ---
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
echo "[install] creating venv"
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
echo "[install] installing dependencies"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt"
|
||||||
|
|
||||||
|
# --- Learn USB serial numbers if requested ---
|
||||||
|
if [ "$1" = "--learn" ]; then
|
||||||
|
echo "[install] learning eye USB serials (both eyes must be plugged in)"
|
||||||
|
"$VENV_DIR/bin/python" "$INSTALL_DIR/eye_service_mk2.py" --learn
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Install systemd service ---
|
||||||
|
echo "[install] installing systemd service"
|
||||||
|
mkdir -p "$HOME/.config/systemd/user"
|
||||||
|
cp "$SCRIPT_DIR/vixy-eyes-mk2.service" "$HOME/.config/systemd/user/$SERVICE_NAME.service"
|
||||||
|
|
||||||
|
# Patch paths in case home dir differs from build machine
|
||||||
|
sed -i "s|/home/alex/eyes-mk2|$INSTALL_DIR|g" \
|
||||||
|
"$HOME/.config/systemd/user/$SERVICE_NAME.service"
|
||||||
|
sed -i "s|User=alex|User=$USER|g" \
|
||||||
|
"$HOME/.config/systemd/user/$SERVICE_NAME.service"
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "$SERVICE_NAME"
|
||||||
|
systemctl --user start "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo "[install] done"
|
||||||
|
echo ""
|
||||||
|
echo " status: systemctl --user status $SERVICE_NAME"
|
||||||
|
echo " logs: journalctl --user -u $SERVICE_NAME -f"
|
||||||
|
echo " stop: systemctl --user stop $SERVICE_NAME"
|
||||||
|
echo " restart: systemctl --user restart $SERVICE_NAME"
|
||||||
|
echo ""
|
||||||
|
if [ "$1" != "--learn" ]; then
|
||||||
|
echo " If this is the first install, plug in both eyes and run:"
|
||||||
|
echo " $INSTALL_DIR/.venv/bin/python $INSTALL_DIR/eye_service_mk2.py --learn"
|
||||||
|
echo " Then: systemctl --user restart $SERVICE_NAME"
|
||||||
|
fi
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pyserial>=3.5
|
||||||
14
vixy-eyes-mk2.service
Normal file
14
vixy-eyes-mk2.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Vixy Eye Display Service Mk2
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=alex
|
||||||
|
WorkingDirectory=/home/alex/eyes-mk2
|
||||||
|
ExecStart=/home/alex/eyes-mk2/.venv/bin/python /home/alex/eyes-mk2/eye_service_mk2.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
217
vixy_eye/vixy_eye.ino
Normal file
217
vixy_eye/vixy_eye.ino
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/*
|
||||||
|
* Vixy Eye Service - Mk2
|
||||||
|
* T-Display-S3-AMOLED-1.75 (CO5300 / H0175Y003AM, 466x466, QSPI)
|
||||||
|
*
|
||||||
|
* Protocol (plain text, newline-terminated):
|
||||||
|
* S:idle\n → set state
|
||||||
|
* G:127:127\n → set gaze x:y (0-255, 127=center)
|
||||||
|
* T:1234567\n → sync animation clock (millis from Pi)
|
||||||
|
*
|
||||||
|
* States: I=idle L=listening R=responding P=pleasure
|
||||||
|
* T=thinking Y=playful C=commanding V=love S=sleep
|
||||||
|
*
|
||||||
|
* Build: Arduino IDE, ESP32S3 Dev Module
|
||||||
|
* USB CDC On Boot: Enabled
|
||||||
|
* Flash: 16MB, PSRAM: OPI PSRAM
|
||||||
|
*
|
||||||
|
* Library: Arduino_GFX-1.3.7 (LilyGo fork, patched)
|
||||||
|
* Note: Uses Canvas (PSRAM framebuffer) to work around QSPI address window bug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Arduino_GFX_Library.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
// === Pins from LILYGO pin_config.h (H0175Y003AM) ===
|
||||||
|
#define LCD_CS 10
|
||||||
|
#define LCD_SCLK 12
|
||||||
|
#define LCD_SDIO0 11
|
||||||
|
#define LCD_SDIO1 13
|
||||||
|
#define LCD_SDIO2 14
|
||||||
|
#define LCD_SDIO3 15
|
||||||
|
#define LCD_RST 17
|
||||||
|
#define LCD_EN 16
|
||||||
|
|
||||||
|
#define DISP_W 466
|
||||||
|
#define DISP_H 466
|
||||||
|
#define CX 233
|
||||||
|
#define CY 233
|
||||||
|
|
||||||
|
Arduino_DataBus *bus = new Arduino_ESP32QSPI(
|
||||||
|
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3
|
||||||
|
);
|
||||||
|
// CO5300 driver for 1.75" H0175Y003AM
|
||||||
|
Arduino_CO5300 *display = new Arduino_CO5300(bus, LCD_RST, 0, false, DISP_W, DISP_H, 6, 0, 0, 0);
|
||||||
|
// Canvas: draw to PSRAM buffer, flush whole frame at once (workaround for QSPI address window bug)
|
||||||
|
Arduino_Canvas *gfx = new Arduino_Canvas(DISP_W, DISP_H, display);
|
||||||
|
|
||||||
|
// === Eye geometry (scaled for 466px) ===
|
||||||
|
const int OUTER_R = 140;
|
||||||
|
const int BASE_IRIS = 90;
|
||||||
|
const int PULSE_RNG = 15;
|
||||||
|
const int SEG_COUNT = 12;
|
||||||
|
const int SEG_W = 10;
|
||||||
|
|
||||||
|
// === State ===
|
||||||
|
char eyeState = 'I'; // current state char
|
||||||
|
int gazeX = 127; // 0-255, 127=center
|
||||||
|
int gazeY = 127;
|
||||||
|
long clockOffset = 0; // ms offset for sync
|
||||||
|
float angleOffset = 0.0f;
|
||||||
|
unsigned long frameStart = 0;
|
||||||
|
const int FRAME_MS = 50; // 20fps
|
||||||
|
|
||||||
|
// === State color + pulse params ===
|
||||||
|
struct EyeParams { uint8_t r, g, b; float spd, amp; };
|
||||||
|
|
||||||
|
// === Color helper ===
|
||||||
|
uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
return gfx->color565(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Gaze: map 0-255 to pixel offset ===
|
||||||
|
int gazeOffset(int v, int maxOffset) {
|
||||||
|
return (int)((v - 127) / 127.0f * maxOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
EyeParams getParams(char state, float t) {
|
||||||
|
switch (state) {
|
||||||
|
case 'I': { // idle - breathing cyan
|
||||||
|
uint8_t v = (uint8_t)(t * 63);
|
||||||
|
return {0, (uint8_t)(192+v), (uint8_t)(192+v), 0.5f, 1.0f};
|
||||||
|
}
|
||||||
|
case 'L': return {160, 255, 255, 4.0f, 0.5f}; // listening
|
||||||
|
case 'R': return {100, 220, 255, 5.0f, 1.5f}; // responding
|
||||||
|
case 'P': return {180, 150, 255, 2.0f, 0.5f}; // pleasure
|
||||||
|
case 'T': { // thinking - amber
|
||||||
|
uint8_t g2 = (uint8_t)(160 + t*40);
|
||||||
|
return {255, g2, (uint8_t)(50+t*30), 1.5f, 1.0f};
|
||||||
|
}
|
||||||
|
case 'Y': return {255, 130, 90, 6.0f, 1.2f}; // playful
|
||||||
|
case 'C': return {220, 50, 120, 3.0f, 0.8f}; // commanding
|
||||||
|
case 'V': { // love - soft pink
|
||||||
|
uint8_t g2 = (uint8_t)(140 + t*40);
|
||||||
|
return {255, g2, (uint8_t)(170+t*30), 0.8f, 0.6f};
|
||||||
|
}
|
||||||
|
case 'S': return {20, 25, 50, 0.2f, 0.3f}; // sleep
|
||||||
|
default: return {128, 128, 128, 1.0f, 1.0f};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Parse incoming serial commands ===
|
||||||
|
void handleSerial() {
|
||||||
|
if (!Serial.available()) return;
|
||||||
|
String line = Serial.readStringUntil('\n');
|
||||||
|
line.trim();
|
||||||
|
if (line.length() < 3) return;
|
||||||
|
|
||||||
|
char cmd = line.charAt(0);
|
||||||
|
if (line.charAt(1) != ':') return;
|
||||||
|
String val = line.substring(2);
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'S': // S:idle or S:I (accept both full name or char)
|
||||||
|
if (val.length() > 0) {
|
||||||
|
// Map full names to chars for convenience
|
||||||
|
if (val == "idle") eyeState = 'I';
|
||||||
|
else if (val == "listening") eyeState = 'L';
|
||||||
|
else if (val == "responding") eyeState = 'R';
|
||||||
|
else if (val == "pleasure") eyeState = 'P';
|
||||||
|
else if (val == "thinking") eyeState = 'T';
|
||||||
|
else if (val == "playful") eyeState = 'Y';
|
||||||
|
else if (val == "commanding") eyeState = 'C';
|
||||||
|
else if (val == "love") eyeState = 'V';
|
||||||
|
else if (val == "sleep") eyeState = 'S';
|
||||||
|
else eyeState = val.charAt(0); // single char fallback
|
||||||
|
Serial.printf("ok:S:%c\n", eyeState);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'G': { // G:x:y
|
||||||
|
int sep = val.indexOf(':');
|
||||||
|
if (sep > 0) {
|
||||||
|
gazeX = val.substring(0, sep).toInt();
|
||||||
|
gazeY = val.substring(sep+1).toInt();
|
||||||
|
gazeX = constrain(gazeX, 0, 255);
|
||||||
|
gazeY = constrain(gazeY, 0, 255);
|
||||||
|
Serial.printf("ok:G:%d:%d\n", gazeX, gazeY);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'T': { // T:timestamp_ms - sync animation clock
|
||||||
|
long piTime = val.toInt();
|
||||||
|
clockOffset = piTime - (long)millis();
|
||||||
|
Serial.printf("ok:T:%ld\n", piTime);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Draw one eye frame ===
|
||||||
|
void drawEye() {
|
||||||
|
long now = (long)millis() + clockOffset;
|
||||||
|
float sec = now / 1000.0f;
|
||||||
|
float t = (sinf(2*PI * sec / 8.0f) + 1.0f) / 2.0f; // 0..1 breathing
|
||||||
|
|
||||||
|
EyeParams p = getParams(eyeState, t);
|
||||||
|
float pulse = sinf(sec * p.spd) * PULSE_RNG * p.amp;
|
||||||
|
int irisR = (int)(BASE_IRIS + pulse);
|
||||||
|
|
||||||
|
// Gaze offset in pixels (max ±30px)
|
||||||
|
int ox = CX + gazeOffset(gazeX, 30);
|
||||||
|
int oy = CY + gazeOffset(gazeY, 20);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
gfx->fillScreen(rgb(10, 0, 20));
|
||||||
|
|
||||||
|
// Gradient iris
|
||||||
|
for (int r = OUTER_R; r >= irisR; r -= 2) {
|
||||||
|
float a = 1.0f - (float)(r - irisR) / (float)(OUTER_R - irisR);
|
||||||
|
gfx->drawCircle(ox, oy, r,
|
||||||
|
rgb((uint8_t)(p.r*a), (uint8_t)(p.g*a), (uint8_t)(p.b*a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid iris center
|
||||||
|
gfx->fillCircle(ox, oy, irisR, rgb(p.r, p.g, p.b));
|
||||||
|
|
||||||
|
// Segmented platinum outer ring
|
||||||
|
uint16_t plat = rgb(192, 192, 192);
|
||||||
|
float arcExt = (2*PI / SEG_COUNT) * 0.6f;
|
||||||
|
for (int i = 0; i < SEG_COUNT; i++) {
|
||||||
|
float start = angleOffset + (2*PI * i / SEG_COUNT);
|
||||||
|
for (float a = start; a < start + arcExt; a += 0.05f) {
|
||||||
|
gfx->drawLine(
|
||||||
|
ox + (int)((OUTER_R - SEG_W/2) * cosf(a)),
|
||||||
|
oy + (int)((OUTER_R - SEG_W/2) * sinf(a)),
|
||||||
|
ox + (int)((OUTER_R + SEG_W/2) * cosf(a)),
|
||||||
|
oy + (int)((OUTER_R + SEG_W/2) * sinf(a)),
|
||||||
|
plat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
angleOffset += 0.002f;
|
||||||
|
if (angleOffset > 2*PI) angleOffset -= 2*PI;
|
||||||
|
|
||||||
|
gfx->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Setup ===
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
pinMode(LCD_EN, OUTPUT);
|
||||||
|
digitalWrite(LCD_EN, HIGH);
|
||||||
|
delay(100);
|
||||||
|
gfx->begin();
|
||||||
|
display->Display_Brightness(255);
|
||||||
|
gfx->fillScreen(rgb(0, 0, 0));
|
||||||
|
gfx->flush();
|
||||||
|
Serial.println("ok:ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Loop ===
|
||||||
|
void loop() {
|
||||||
|
handleSerial();
|
||||||
|
unsigned long now = millis();
|
||||||
|
if (now - frameStart >= FRAME_MS) {
|
||||||
|
drawEye();
|
||||||
|
frameStart = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user