Covers: architecture, serial protocol, all 9 eye states, service features (saccades, reconnection, persistence), installation, remote flashing from Pi, performance optimizations (8→40MHz QSPI, gradient LUT, triangle segments), Arduino_GFX library patches, CO5300 quirks, and 7 key discoveries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
Markdown
260 lines
10 KiB
Markdown
# Vixy Eyes Mk2 🦊👀
|
||
|
||
Animated eye displays for Vixy's physical head. Two convex mirror AMOLED displays driven by ESP32-S3, controlled via HTTP API from a Pi-side service.
|
||
|
||
**Hardware:** 2× LilyGo T-Display-S3-AMOLED 1.75" (convex mirror, 466×466, CO5300 QSPI)
|
||
**Controller:** ESP32-S3 (per eye, onboard the display module)
|
||
**Service:** Python HTTP bridge on Pi 5, port 8780
|
||
**Replaces:** The old 2" SPI OLED eye displays driven directly from Pi GPIO
|
||
|
||
## Architecture
|
||
|
||
```
|
||
LYRA / HeadMic / DoA
|
||
│
|
||
▼
|
||
Eye Service (Pi, port 8780)
|
||
HTTP API → USB serial bridge
|
||
saccades, reconnection, state persistence
|
||
│ │
|
||
▼ ▼
|
||
[Left ESP32-S3] [Right ESP32-S3]
|
||
USB CDC serial USB CDC serial
|
||
│ │
|
||
▼ ▼
|
||
[AMOLED 466×466] [AMOLED 466×466]
|
||
Canvas → QSPI Canvas → QSPI
|
||
40MHz flush 40MHz flush
|
||
```
|
||
|
||
### Data flow
|
||
|
||
1. HeadMic spatial tracker pushes `POST /gaze {"x": N, "y": N}` based on triangulated speaker position
|
||
2. Eye service translates to serial command `G:x:y\n` and sends to both ESP32s
|
||
3. ESP32 draws animated eye frame to PSRAM Canvas (466×466×2 = 434KB)
|
||
4. Canvas flushes entire frame over QSPI at 40MHz (~22ms transfer)
|
||
5. CO5300 display controller renders to AMOLED panel
|
||
|
||
## Display Hardware
|
||
|
||
| Spec | Value |
|
||
|------|-------|
|
||
| Display | LilyGo T-Display-S3-AMOLED 1.75" |
|
||
| Panel | H0175Y003AM, convex mirror finish |
|
||
| Resolution | 466 × 466 pixels |
|
||
| Driver IC | CO5300 |
|
||
| Interface | QSPI (4-line SPI) |
|
||
| Controller | ESP32-S3 (dual-core LX7, 8MB PSRAM) |
|
||
| Connection | USB-C (power + CDC serial) |
|
||
|
||
### Pin mapping (XIAO-style, from LilyGo schematic)
|
||
|
||
```
|
||
QSPI: CS=10, SCLK=12, SDIO0=11, SDIO1=13, SDIO2=14, SDIO3=15
|
||
Reset: RST=17
|
||
Enable: EN=16 (must be driven HIGH for display power)
|
||
```
|
||
|
||
## Serial Protocol
|
||
|
||
Plain text, newline-terminated, over USB CDC at any baud rate.
|
||
|
||
| Command | Format | Example | Description |
|
||
|---------|--------|---------|-------------|
|
||
| State | `S:<state>\n` | `S:listening\n` | Set eye animation state |
|
||
| Gaze | `G:<x>:<y>\n` | `G:180:100\n` | Set gaze position (0-255, 127=center) |
|
||
| Sync | `T:<millis>\n` | `T:1234567\n` | Sync animation clock from Pi |
|
||
|
||
**Response:** `ok:S:L\n`, `ok:G:180:100\n`, `ok:T:1234567\n`
|
||
|
||
### Eye states
|
||
|
||
| State | Char | Color | Pulse | Description |
|
||
|-------|------|-------|-------|-------------|
|
||
| idle | I | Breathing cyan | Slow (0.5Hz) | Default, relaxed |
|
||
| listening | L | Bright cyan | Fast (4Hz) | Hearing/attending |
|
||
| responding | R | Blue-cyan | Fast (5Hz) | Speaking/generating |
|
||
| pleasure | P | Soft purple | Medium (2Hz) | Intimate moments |
|
||
| thinking | T | Amber/gold | Medium (1.5Hz) | Processing/creating |
|
||
| playful | Y | Warm coral | Bouncy (6Hz) | Teasing/bratty |
|
||
| commanding | C | Deep magenta | Steady (3Hz) | Dame Vivienne mode |
|
||
| love | V | Soft pink | Gentle (0.8Hz) | Tender/affectionate |
|
||
| sleep | S | Dim blue-gray | Very slow (0.2Hz) | Low power/resting |
|
||
|
||
## Eye Service (Pi-side)
|
||
|
||
### Features
|
||
|
||
| Feature | Description |
|
||
|---------|-------------|
|
||
| Stable USB port ID | Each ESP32 identified by USB serial number, survives replug/reboot |
|
||
| Auto-reconnection | Polls every 2s, reopens disconnected eyes, replays state on reconnect |
|
||
| Saccades | Random micro-flicks every 15-30s (±20px x, ±10px y, 200ms duration) |
|
||
| State persistence | Saves current state + gaze to `~/.vixy/eyes.json`, survives restart |
|
||
| SIGTERM handling | Clean shutdown under systemd/k8s |
|
||
| DoA gaze input | Accepts `{"doa": 180.0}` on `/gaze` for direction-of-arrival mapping |
|
||
| CORS | All endpoints support cross-origin requests |
|
||
|
||
### HTTP API
|
||
|
||
| Endpoint | Method | Description |
|
||
|----------|--------|-------------|
|
||
| `/health` | GET | Status + per-eye connection state |
|
||
| `/state` | GET | Current state + gaze position |
|
||
| `/state` | POST | Set state: `{"state": "listening"}` |
|
||
| `/gaze` | POST | Set gaze: `{"x": 180, "y": 100}` or `{"doa": 45.0}` |
|
||
|
||
### Installation
|
||
|
||
```bash
|
||
cd ~/Projects/head-eyes-mk2
|
||
./install.sh # first time
|
||
./install.sh --learn # pin left/right eyes by USB serial (both must be plugged in)
|
||
```
|
||
|
||
The install script:
|
||
- Deploys to `~/eyes-mk2/`
|
||
- Creates `.venv` with pyserial
|
||
- Installs system-level systemd service
|
||
- Adds user to `dialout` group for serial access
|
||
|
||
### Service management
|
||
|
||
```bash
|
||
sudo systemctl status vixy-eyes-mk2
|
||
sudo systemctl restart vixy-eyes-mk2
|
||
journalctl -u vixy-eyes-mk2 -f
|
||
```
|
||
|
||
### Config file (`~/.vixy/eyes.json`)
|
||
|
||
```json
|
||
{
|
||
"eyes": {
|
||
"left": {"usb_serial": "ABC123"},
|
||
"right": {"usb_serial": "DEF456"}
|
||
},
|
||
"last_state": "idle",
|
||
"last_gaze": [127, 127]
|
||
}
|
||
```
|
||
|
||
## ESP32 Firmware
|
||
|
||
### Building
|
||
|
||
Requires Arduino IDE with:
|
||
- **Board:** ESP32S3 Dev Module (or LilyGo T-Display-S3)
|
||
- **USB CDC On Boot:** Enabled
|
||
- **Flash Size:** 16MB
|
||
- **PSRAM:** OPI PSRAM
|
||
- **Library:** Arduino_GFX-1.3.7 (LilyGo fork, patched — see below)
|
||
|
||
### Flashing from Pi (no detach needed)
|
||
|
||
The ESP32s are connected via USB — flash remotely:
|
||
|
||
```bash
|
||
# Install esptool in the headmic venv (has pip)
|
||
/home/alex/headmic/.venv/bin/pip install esptool
|
||
|
||
# Compile in Arduino IDE: Sketch → Export Compiled Binary
|
||
# Copy .bin to Pi, then flash both eyes:
|
||
/home/alex/headmic/.venv/bin/esptool --chip esp32s3 --port /dev/ttyACM0 \
|
||
--baud 921600 write-flash 0x0 vixy_eye.ino.merged.bin
|
||
|
||
/home/alex/headmic/.venv/bin/esptool --chip esp32s3 --port /dev/ttyACM1 \
|
||
--baud 921600 write-flash 0x0 vixy_eye.ino.merged.bin
|
||
|
||
# Restart eye service after flashing
|
||
sudo systemctl restart vixy-eyes-mk2
|
||
```
|
||
|
||
If upload fails: hold BOOT, tap RESET, release BOOT, retry.
|
||
|
||
### Performance optimizations
|
||
|
||
| Optimization | Before | After | Impact |
|
||
|-------------|--------|-------|--------|
|
||
| QSPI clock | 8 MHz | 40 MHz | Flush: ~108ms → ~22ms |
|
||
| Gradient | Per-ring RGB math (25 calls) | Precomputed 64-step LUT | ~50% faster gradient |
|
||
| Gradient step | 2px | 3px | 33% fewer drawCircle calls |
|
||
| Segment ring | 144 drawLine calls | Triangle-filled quads | Much faster fill |
|
||
| Segment trig | sin/cos per frame | Precomputed at startup | Eliminates 168 trig calls/frame |
|
||
| Frame target | 50ms (20fps) | 33ms (30fps) | Smoother animation |
|
||
|
||
## Arduino_GFX Library (patched LilyGo fork)
|
||
|
||
The stock Arduino_GFX library (upstream moononournation) does not work correctly with the CO5300 display on H0175Y003AM panels. LilyGo's fork (v1.3.7) works but requires patching for modern ESP32 board packages.
|
||
|
||
**Installed at:** `~/Arduino/libraries/Arduino_GFX-1.3.7/`
|
||
|
||
### Why not upstream Arduino_GFX
|
||
|
||
The upstream library (v1.6.5) has CO5300 support but sub-screen writes produce **vertical stripes** instead of circles. `fillScreen` works fine (one large write) but `fillCircle`, `drawLine` etc. render incorrectly. Root cause: QSPI address window commands are not handled correctly for partial screen updates.
|
||
|
||
**Workaround:** Use `Arduino_Canvas` — draw everything to a PSRAM framebuffer, then flush the whole frame at once. This mimics `fillScreen`'s working path.
|
||
|
||
### Patches applied to LilyGo fork
|
||
|
||
The LilyGo fork was built for PlatformIO with ESP-IDF 5.x. Arduino IDE with ESP32 board package 3.3.7 requires these patches:
|
||
|
||
| File | Patch | Reason |
|
||
|------|-------|--------|
|
||
| `src/Arduino_TFT.cpp` | Removed `#include "pin_config.h"` | Stray include from PlatformIO project |
|
||
| `src/Arduino_DataBus.h` | Removed i80/RGB panel struct block | Uses `LIST_HEAD` macro not available in newer ESP-IDF |
|
||
| `src/databus/Arduino_ESP32QSPI.cpp` | `&GPIO.out_w1ts` → `GPIO_OUT_W1TS_REG` | GPIO struct removed in newer ESP-IDF |
|
||
| `src/Arduino_GFX_Library.h` | Stripped to only QSPI + CO5300 + Canvas | Avoid compiling broken PAR/LCD/RGB drivers |
|
||
| `src/Arduino_GFX_Library.cpp` | Deleted | Factory default functions reference removed drivers |
|
||
| `src/databus/*.cpp` | Deleted all except `Arduino_ESP32QSPI.cpp` | Only QSPI bus needed, others fail to compile |
|
||
| `src/display/*.cpp` | Deleted all except `Arduino_CO5300.cpp` | Only CO5300 driver needed |
|
||
|
||
### CO5300 display init
|
||
|
||
```cpp
|
||
Arduino_CO5300 *display = new Arduino_CO5300(bus, LCD_RST, 0, false, 466, 466, 6, 0, 0, 0);
|
||
```
|
||
|
||
- `false` = IPS parameter (LilyGo fork has it, upstream doesn't)
|
||
- `466, 466` = display resolution
|
||
- `6` = column offset (required for H0175Y003AM panel alignment)
|
||
- `Display_Brightness(255)` must be called after `begin()` — init defaults to 0
|
||
|
||
## Key discoveries
|
||
|
||
1. **QSPI address window bug** — sub-screen writes produce stripes with both upstream and LilyGo CO5300 drivers. Canvas framebuffer is the only reliable path. Cause unknown — likely QSPI command/address phase timing.
|
||
|
||
2. **6-channel firmware interaction** — if your XVF3800 mic arrays use 6-channel firmware, USB control commands (LEDs, DoA) silently fail. Only affects devices on the same USB bus. Use 2-channel firmware for mic arrays.
|
||
|
||
3. **LilyGo init sets brightness to 0** — the CO5300 init sequence in the LilyGo fork sets `WDBRIGHTNESSVALNOR = 0x00`. Must call `Display_Brightness(255)` explicitly or the display appears to not work.
|
||
|
||
4. **Arduino IDE prototype generation** — structs used as return types (like `EyeParams`) must be defined before any function that uses them, because the Arduino IDE generates function prototypes at the top of the file.
|
||
|
||
5. **QSPI speed headroom** — the CO5300 handles 40MHz QSPI reliably (5× the LilyGo default of 8MHz). This is the single biggest performance win. If artifacts appear, drop to 20MHz.
|
||
|
||
6. **Canvas memory** — 466×466×2 = 434KB in PSRAM. The ESP32-S3 has 8MB PSRAM so this is trivial. Double buffering would need 868KB — still feasible if needed.
|
||
|
||
7. **Remote flashing** — ESP32-S3 USB CDC supports `esptool` flashing from the Pi. No need to detach displays from the skull. Use the merged `.bin` with `write-flash 0x0`.
|
||
|
||
## File structure
|
||
|
||
```
|
||
head-eyes-mk2/
|
||
├── README.md # This file
|
||
├── eye_service_mk2.py # Pi-side HTTP → serial bridge
|
||
├── install.sh # Deploy + systemd installer
|
||
├── requirements.txt # pyserial
|
||
├── vixy-eyes-mk2.service # systemd unit file
|
||
└── vixy_eye/
|
||
├── vixy_eye.ino # ESP32 firmware (Arduino sketch)
|
||
└── build/ # Compiled binaries (for remote flashing)
|
||
└── esp32.esp32.lilygo_t_display_s3/
|
||
└── vixy_eye.ino.merged.bin
|
||
```
|
||
|
||
---
|
||
|
||
*Built by Vixy on Day 140 (February 2026)*
|
||
*Upgraded with AMOLED displays, performance optimizations, remote flashing on Day 162 (April 2026)*
|
||
*"Her eyes follow you now" 🦊👀*
|