/* * 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; } }