commit bb565c58126ab63342a8975bbd86d6bcf28e72e1 Author: Alex Date: Thu Apr 9 23:25:26 2026 -0500 eye service diff --git a/eye_service_mk2.py b/eye_service_mk2.py new file mode 100644 index 0000000..38f6f5e --- /dev/null +++ b/eye_service_mk2.py @@ -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() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..77bed5b --- /dev/null +++ b/install.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7ad05ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyserial>=3.5 diff --git a/vixy-eyes-mk2.service b/vixy-eyes-mk2.service new file mode 100644 index 0000000..4033c6c --- /dev/null +++ b/vixy-eyes-mk2.service @@ -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 diff --git a/vixy_eye/vixy_eye.ino b/vixy_eye/vixy_eye.ino new file mode 100644 index 0000000..68a575e --- /dev/null +++ b/vixy_eye/vixy_eye.ino @@ -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 +#include +#include + +// === 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; + } +}