Slab eyes

Landscape Arduino eyes on the C6 slab. They look around and wink on their own. Short BOOT pokes them; hold BOOT cycles looks.

Slab eyes
DifficultyBeginner
Build time40 min
Est. cost$18
Parts4
Files5
Steps3

Things you'll need

QtyPartType
1
Waveshare ESP32-C6-LCD-1.47 (silhouette slab)
Waveshare ESP32-C6-LCD-1.47 (silhouette slab) 172×320 ST7789, GPIO8 WS2812, TF slot, BOOT/RESET. No touch or IMU. Amazon B0DK5J6LX3.
electronics
1
Eyes bezel
Eyes bezel Landscape window that frames two egg eyes. Print in a dark PLA so the sclera pops.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 12–20 g for the snap shells.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.

What you're building

A pair of landscape eyes that live on your desk. They look around, wink, fidget, get drowsy, and fall asleep. Short BOOT pokes them. Hold BOOT cycles looks — cloud, glowstick, matcha, ghost, and more.

There is no camera and no microphone. The “life” is math and timers. That is enough to feel like a creature.

What you will learn

  • That an animation is an update loop: time passes, positions move, we redraw.
  • How coordinates place two ellipses (eyes) on a 320×172 stage.
  • How a mood (happy, sleepy, dizzy) is a bundle of behaviors.
  • Why this chip prefers whole numbers for speed (no FPU).

Meet the silhouette slab

This project uses the Waveshare ESP32-C6-LCD-1.47 — a slim rectangle with a 1.47 inch screen.

  • A 172×320 color screen. You can turn it portrait or landscape in software.
  • No touch. No motion sensor. Interaction is the BOOT button (a short press vs a ~0.7 second hold).
  • A single RGB LED on the board (GPIO8) that can glow any color.
  • Wi-Fi that is 2.4 GHz only.
  • Keep the backlight at 50%. Full brightness can leave a heat spot on the panel.

The program is a .ino file (Arduino-style C++). You will not memorize it. You will learn the ideas, then see the small piece of code that does each job.

Note

Word to know — GPIO. It means “general purpose input/output” — a pin the chip can turn on or read. BOOT is GPIO9. The RGB LED is GPIO8. You do not rewire anything; the board already connected those pins.

The finished gadget

Slab eyes
Slab eyes on Silhouette slab (ESP32-C6).

Build it

1

Print the bezel

Dark PLA, 0.2 mm. Landscape window, USB-C at the end. Dark plastic makes the white of the eye pop.

Print the bezel
2

Flash the slab

Flash means copy the program onto the chip. Easiest path: use the attached slab-eyes.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32C6 Dev Module, USB CDC On Boot, 115200 — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. Keep the backlight at 50%. This slab has no touch screen and no motion sensor — you press BOOT (the button on GPIO9) to interact.

Flash the slab
3

Let them live

Leave it on the desk. Short BOOT pokes. Hold BOOT changes the look. Serial mood sleepy or wink if you want to direct.

Let them live

Lesson 1 — A creature is a loop

Every frame (many times a second):

  1. How much time just passed? (dt)
  2. Move the gaze a little toward a target.
  3. Maybe blink. Maybe pick a new target. Maybe get sleepy.
  4. Draw both eyes into a buffer.
  5. Show the buffer.

That is the game loop. Platformers, birds, and these eyes all share it. dt is clamped so a hitch does not teleport the pupils.

Lesson 2 — Two ellipses on a stage

The screen is 320 wide and 172 tall (landscape). The left eye is centered near x=86, the right near x=234, both around y=86. An ellipse is a stretched circle — width EYE_A, height EYE_B. Pupils sit inside and slide when the gaze target moves.

You can graph this on paper. The code is that graph, filled with color.

The stage directions

slab-eyes.ino
static const int FB_W = 320;
static const int FB_H = 172;
static const float EYE_L_X = 86;   // left eye center
static const float EYE_R_X = 234;  // right eye center
static const float EYE_Y = 86;

Lesson 3 — Moods are behavior packs

happy might raise the lids and add blush. sleepy droops the lids. dizzy spins the gaze. A mood is not a picture; it is a set of knobs (lid height, how often to blink, where to look). A look (cloud, glowstick) is the paint — sclera color, pupil shape.

Separating “how it acts” from “how it looks” is a design habit you will want in every game.

Looks are palettes; moods are verbs

slab-eyes.ino
{"cloud",      /* cream sclera, dark round pupil */},
{"glowstick",  /* neon sclera, slit pupil */},
{"ghost",      /* pale sclera, star pupil */},

// serial: mood happy | sleepy | curious | love | dizzy | …

Lesson 4 — Whole numbers can be faster

This C6 chip has no FPU (floating-point unit). Fancy decimal math in a tight loop crawled at about 0.1 frames per second — slideshow, not life. The fix was integer scanlines: for each row y, compute the width of the ellipse with an integer square root, write a span of pixels.

The lesson is not “never use decimals.” It is: measure, then pick a representation that fits the hardware. Video games have done this since the 1980s.

Try this

  • Type look list and cycle with BOOT. Which pupil style is a slit?
  • gaze 0 0 then gaze 1 -1 — you are steering both pupils.
  • Leave it alone for a few minutes. Watch drowsy → asleep. Poke to wake.

Talking to the board (serial)

Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).

Type a word, press Enter, and the board answers. Try help first. Then try status.

Commands for this project: mood · look · gaze x y · blink · wink · poke · pet · sleep · wake · status · help

Tip

If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….

Tip

If an older build crawled at a fraction of a frame per second, that was the float version. This one is the integer port. Backlight stays ≤ 50% and dims further when asleep.


The complete program (reference)

Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

slab-eyes.ino (complete)

slab-eyes.ino
// Slab eyes — landscape Arduino eyes for Waveshare ESP32-C6-LCD-1.47
// (Amazon B0DK5J6LX3). LCD-only slab — no touch, no IMU.
//
// Port of robot-face's egg eyes / looks / blink+wink machines, locked
// landscape 320×172 the whole time. Life is autonomous: look-arounds,
// winks, fidgets, drowsy → asleep. BOOT pokes it; hold BOOT cycles looks.
//
// Serial 115200: mood <name> | look <n|next|prev|list> | gaze x y |
//                blink | wink | poke | pet | sleep | wake | status | help
//
// Backlight stays ≤ 50% (Waveshare heat-spot warning); dims further asleep.
// The browser sim is board-sketches/slab-eyes/sim/index.html
//   python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/slab-eyes

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <math.h>

#include "../../../shared/c6_slab_pins.h"

#define BOOT_LONG_MS 700UL

static const int FB_W = 320;
static const int FB_H = 172;

static const uint8_t BG[3] = {12, 13, 18};
static const float EYE_L_X = 86;
static const float EYE_R_X = 234;
static const float EYE_Y = 86;
static const float EYE_A = 58;
static const float EYE_B = 64;
static const float PUPIL_R = 22;
static const float LOOK_TRAVEL_X = 0.50f;
static const float LOOK_TRAVEL_Y = 0.42f;
static const float PAIR_SHIFT_X = 6;
static const float PAIR_SHIFT_Y = 3;
static const float BLUSH_DX = 28;
static const float BLUSH_Y = 148;
static const float BLUSH_R = 16;

enum PupilStyle : uint8_t { ST_NONE = 0, ST_ROUND, ST_DOT, ST_SLIT, ST_STAR, ST_HEART, ST_RING };

struct Look {
  const char *name;
  uint8_t sclera[3];
  uint8_t pupil[3];
  bool hasPupil;
  uint8_t pupil2[3];
  bool hasPupil2;
  bool shine;
  PupilStyle style;
  uint8_t blush[3];
};

// Same palettes as robot-face. Keep in sync with sim/index.html
static const Look LOOKS[] = {
  {"cloud",      {242, 236, 226}, {22, 24, 30},    true,  {0, 0, 0},     false, true,  ST_ROUND, {232, 96, 110}},
  {"periwinkle", {168, 196, 240}, {0, 0, 0},       false, {0, 0, 0},     false, false, ST_NONE,  {225, 110, 150}},
  {"glowstick",  {200, 255, 30},  {10, 10, 16},    true,  {0, 0, 0},     false, false, ST_SLIT,  {255, 120, 80}},
  {"matcha",     {207, 230, 196}, {30, 40, 90},    true,  {190, 80, 20}, true,  false, ST_DOT,   {240, 120, 120}},
  {"blueberry",  {36, 48, 255},   {214, 246, 255}, true,  {0, 0, 0},     false, false, ST_ROUND, {200, 90, 255}},
  {"bubblegum",  {255, 60, 225},  {60, 225, 90},   true,  {0, 0, 0},     false, false, ST_DOT,   {255, 140, 200}},
  {"lagoon",     {28, 120, 190},  {220, 255, 60},  true,  {0, 0, 0},     false, false, ST_SLIT,  {120, 200, 255}},
  {"peach",      {255, 176, 140}, {70, 40, 40},    true,  {0, 0, 0},     false, true,  ST_ROUND, {255, 110, 120}},
  {"ghost",      {250, 250, 255}, {120, 140, 255}, true,  {0, 0, 0},     false, false, ST_STAR,  {190, 200, 255}},
};
static const int LOOK_COUNT = sizeof(LOOKS) / sizeof(LOOKS[0]);
static const uint8_t COL_SHINE[3] = {255, 255, 255};
static const uint8_t COL_HEART[3] = {232, 96, 110};
static const uint8_t COL_TEAR[3] = {160, 210, 255};

enum Mood : uint8_t {
  MOOD_IDLE = 0, MOOD_HAPPY, MOOD_SLEEPY, MOOD_CURIOUS, MOOD_SURPRISED, MOOD_LOVE,
  MOOD_DIZZY, MOOD_ANNOYED, MOOD_SAD, MOOD_EXCITED, MOOD_ASLEEP, MOOD_COUNT
};
static const char *MOOD_NAMES[MOOD_COUNT] = {
  "idle", "happy", "sleepy", "curious", "surprised", "love", "dizzy", "annoyed", "sad", "excited", "asleep"};

enum GestureType : uint8_t {
  G_NONE = 0, G_GLANCE2, G_THINK, G_BROW, G_WIGGLE, G_STARE, G_SIGH, G_STRETCH, G_EYEROLL
};
enum Awake : uint8_t { AWAKE = 0, DROWSY, ASLEEP };

static const uint32_t DROWSY_AFTER_MS = 75000;
static const uint32_t DROWSY_LEN_MS = 12000;
static const uint32_t LONELY_AFTER_MS = 45000;

struct Shape {
  float sx = 1, sy = 1, top = 0, ang = 0, curve = 0, bot = 0, pupil = 1;
};

static Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
static Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation; setRotation(1) in setup */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

static Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
static Preferences prefs;
static uint16_t *fb = nullptr;

static Shape shapeL, shapeR;
static float lookX = 0, lookY = 0, velX = 0, velY = 0;
static float offX = 0, offY = 0, offVelY = 0;
static float blinkL = 0, blinkR = 0, openPop = 0;
static float blush = 0, swirl = 0, heartbeat = 0, breath = 0;
static float tear = 0;
static int tearSide = 1;
static float drowsy = 0;
static float backlight = 1;

static int lookIndex = 0;
static int pendingLook = -1;
static Mood mood = MOOD_IDLE;
static uint32_t moodUntil = 0;
static bool moodHold = false;
static uint32_t moodEnteredAt = 0;
static bool pokeHappyPending = false;

static float targetLookX = 0, targetLookY = 0;
static bool glancing = false;
static uint32_t glanceUntil = 0;
static uint32_t nextGlanceAt = 0;
static uint32_t nextAutoMoodAt = 0;
static uint32_t nextFidgetAt = 0;
static uint32_t nextMicroAt = 0;
static float microX = 0, microY = 0;

static uint32_t nextBlinkAt = 0;
static uint32_t blinkStart = 0;
static uint32_t blinkClose = 75, blinkHold = 35, blinkOpen = 160;
static bool blinking = false;
static bool blinkAgain = false;
static uint8_t winkEye = 0;
static uint32_t winkStart = 0;
static uint32_t winkDur = 0;
static uint32_t nextWinkAt = 0;

static GestureType gesture = G_NONE;
static uint32_t gestureStart = 0, gestureDur = 0;
static int gestureSide = 1;
static float gAx = 0, gAy = 0, gBx = 0, gBy = 0;

static uint32_t lastInteraction = 0;
static Awake awake = AWAKE;
static uint32_t drowsySince = 0;
static int petCount = 0;
static uint32_t lastPetAt = 0;

static PupilStyle frameStyle = ST_ROUND;
static float frameHeartPulse = 1;
static float frameOffX = 0, frameOffY = 0;
static float frameBlinkL = 0, frameBlinkR = 0;
static uint8_t blushCol[3] = {0, 0, 0};

static bool g_bootDown = false;
static bool g_bootLong = false;
static uint32_t g_bootDownAt = 0;
static String g_line;
static int dirtyTop = 0, dirtyBot = FB_H - 1;
static int prevTop = 0, prevBot = FB_H - 1;
static uint32_t frames = 0;
static uint32_t nextStatsAt = 0;

static float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
static float lerpf(float a, float b, float t) { return a + (b - a) * t; }
static float easeOut(float t) { t = clampf(t, 0, 1); return 1.0f - (1.0f - t) * (1.0f - t); }
static float easeIn(float t) { t = clampf(t, 0, 1); return t * t; }
static float randf(float a, float b) { return a + (b - a) * (random(0, 10000) / 10000.0f); }
static bool chance(float p) { return randf(0, 1) < p; }
static int32_t since(uint32_t now, uint32_t then) { return (int32_t)(now - then); }
static bool after(uint32_t now, uint32_t when) { return since(now, when) >= 0; }

static void startGesture(GestureType type, uint32_t dur, uint32_t now, int side = 1);
static void startBlink(uint32_t now, bool slow);
static void scheduleBlink(uint32_t from);

static void setMood(Mood next, uint32_t holdMs, bool hold = false) {
  const uint32_t now = millis();
  if (next != mood) {
    moodEnteredAt = now;
    if (next == MOOD_SURPRISED) offVelY -= 95;
    if (next == MOOD_HAPPY || next == MOOD_EXCITED) offVelY -= 55;
    if (next == MOOD_SAD) { tear = 0.001f; tearSide = chance(0.5f) ? -1 : 1; }
  }
  mood = next;
  moodUntil = now + holdMs;
  moodHold = hold;
}

static void wakeUp(uint32_t now) {
  awake = AWAKE;
  drowsy = 0;
  startGesture(G_STRETCH, 700, now);
  pokeHappyPending = true;
  setMood(MOOD_SURPRISED, 700);
  scheduleBlink(now + 750);
  blinkAgain = true;
}

static void touched(uint32_t now) {
  lastInteraction = now;
  if (awake != AWAKE) wakeUp(now);
}

static void fallAsleep(uint32_t now) {
  (void)now;
  awake = ASLEEP;
  gesture = G_NONE;
  glancing = false;
  pokeHappyPending = false;
  setMood(MOOD_ASLEEP, 0, true);
}

static void scheduleBlink(uint32_t from) {
  const bool sleepyish = mood == MOOD_SLEEPY || awake == DROWSY;
  nextBlinkAt = from + (sleepyish ? random(1100, 2200) : random(2600, 5200));
}
static void scheduleGlance(uint32_t from) { nextGlanceAt = from + random(2800, 6200); }
static void scheduleAutoMood(uint32_t from) { nextAutoMoodAt = from + random(12000, 22000); }
static void scheduleFidget(uint32_t from) { nextFidgetAt = from + random(4500, 9000); }
static void scheduleWink(uint32_t from) { nextWinkAt = from + random(8000, 18000); }

static void startBlink(uint32_t now, bool slow) {
  blinking = true;
  blinkStart = now;
  if (slow) { blinkClose = 220; blinkHold = 120; blinkOpen = 420; }
  else { blinkClose = 75; blinkHold = 35; blinkOpen = 160; }
}

static float blinkCurve(int32_t u) {
  if (u < 0) return 0;
  if ((uint32_t)u < blinkClose) return easeIn(u / (float)blinkClose);
  if ((uint32_t)u < blinkClose + blinkHold) return 1;
  const float o = (u - (int32_t)blinkClose - (int32_t)blinkHold) / (float)blinkOpen;
  if (o >= 1) return 0;
  return 1 - easeOut(o);
}

static void startGesture(GestureType type, uint32_t dur, uint32_t now, int side) {
  gesture = type;
  gestureStart = now;
  gestureDur = dur;
  gestureSide = side;
  gAx = gAy = gBx = gBy = 0;
  if (type == G_GLANCE2) {
    gAx = (chance(0.5f) ? -1 : 1) * randf(0.5f, 0.85f);
    gAy = randf(-0.35f, 0.3f);
    gBx = -gAx * randf(0.6f, 1.0f);
    gBy = randf(-0.3f, 0.3f);
  }
}

static void pickFidget(uint32_t now) {
  const bool lonely = since(now, lastInteraction) > (int32_t)LONELY_AFTER_MS;
  const float roll = randf(0, 1);
  if (lonely && roll < 0.12f) { setMood(MOOD_SAD, 2400); return; }
  if (roll < 0.22f) startGesture(G_GLANCE2, random(1500, 2200), now);
  else if (roll < 0.40f) startGesture(G_THINK, random(1000, 1500), now, chance(0.5f) ? -1 : 1);
  else if (roll < 0.55f) startGesture(G_BROW, random(800, 1200), now, chance(0.5f) ? -1 : 1);
  else if (roll < 0.65f) { startBlink(now, false); blinkAgain = true; }
  else if (roll < 0.78f) startGesture(G_WIGGLE, 700, now);
  else if (roll < 0.90f) startGesture(G_STARE, random(1300, 1900), now);
  else startGesture(G_SIGH, 1500, now);
}

static void poke() {
  const uint32_t now = millis();
  touched(now);
  pokeHappyPending = true;
  setMood(MOOD_SURPRISED, 380);
}

static void pokeEye(int side) {
  const uint32_t now = millis();
  touched(now);
  winkEye = side < 0 ? 1 : 2;
  winkStart = now;
  winkDur = 460;
  pokeHappyPending = false;
  setMood(MOOD_ANNOYED, 900);
  glancing = true;
  glanceUntil = now + 900;
  targetLookX = -side * 0.75f;
  targetLookY = -0.35f;
  offVelY -= 40;
}

static void pet(int dir) {
  const uint32_t now = millis();
  touched(now);
  petCount = (since(now, lastPetAt) < 5000) ? petCount + 1 : 1;
  lastPetAt = now;
  glancing = true;
  glanceUntil = now + 800;
  targetLookX = dir * 0.8f;
  targetLookY = 0.05f;
  pokeHappyPending = false;
  if (petCount >= 3) {
    petCount = 0;
    setMood(MOOD_LOVE, 2800);
  } else {
    setMood(MOOD_HAPPY, 1100);
  }
}

static void saveLook() { prefs.putUChar("look", (uint8_t)lookIndex); }

static void setLook(int i, bool animate) {
  i = ((i % LOOK_COUNT) + LOOK_COUNT) % LOOK_COUNT;
  const uint32_t now = millis();
  touched(now);
  if (!animate) { lookIndex = i; saveLook(); return; }
  pendingLook = i;
  startBlink(now, false);
}

static void forceMood(Mood m, uint32_t holdMs) {
  const uint32_t now = millis();
  if (m == MOOD_ASLEEP) { lastInteraction = now - DROWSY_AFTER_MS - 1; fallAsleep(now); return; }
  touched(now);
  pokeHappyPending = false;
  setMood(m, holdMs, m == MOOD_IDLE);
}

static void startWinkNow(uint32_t now) {
  if (blinking || awake != AWAKE) return;
  winkEye = chance(0.6f) ? 1 : 2;
  winkStart = now;
  winkDur = 280;
}

static void update(uint32_t now, float dt) {
  if (awake == AWAKE && since(now, lastInteraction) > (int32_t)DROWSY_AFTER_MS && mood != MOOD_LOVE) {
    awake = DROWSY;
    drowsySince = now;
    gesture = G_NONE;
    pokeHappyPending = false;
    setMood(MOOD_SLEEPY, 0, true);
  }
  if (awake == DROWSY) {
    drowsy = clampf(since(now, drowsySince) / (float)DROWSY_LEN_MS, 0, 1);
    if (drowsy >= 1) fallAsleep(now);
  }
  backlight = lerpf(backlight, awake == ASLEEP ? 0.35f : 1.0f, 0.04f);

  if (mood != MOOD_IDLE && !moodHold && after(now, moodUntil)) {
    if (pokeHappyPending) {
      pokeHappyPending = false;
      setMood(MOOD_HAPPY, 1800);
    } else {
      mood = MOOD_IDLE;
    }
  }

  const bool asleep = awake == ASLEEP;
  if (!blinking && !asleep && after(now, nextBlinkAt) && mood != MOOD_LOVE && mood != MOOD_DIZZY) {
    startBlink(now, mood == MOOD_SLEEPY || awake == DROWSY);
  }
  if (blinking) {
    const int32_t u = since(now, blinkStart);
    blinkL = blinkCurve(u);
    blinkR = blinkCurve(u - 14);
    if (pendingLook >= 0 && u >= (int32_t)blinkClose) {
      lookIndex = pendingLook;
      pendingLook = -1;
      saveLook();
    }
    if (u >= (int32_t)(blinkClose + blinkHold + blinkOpen + 14)) {
      blinking = false;
      blinkL = blinkR = 0;
      openPop = 1;
      if (blinkAgain) { blinkAgain = false; startBlink(now + 60, false); }
      else scheduleBlink(now);
    }
  } else if (asleep) {
    blinkL = lerpf(blinkL, 1, 0.06f);
    blinkR = lerpf(blinkR, 1, 0.06f);
  } else {
    blinkL = blinkR = 0;
  }
  openPop *= 0.82f;

  if (winkEye == 0 && after(now, nextWinkAt) && mood == MOOD_IDLE && !blinking && awake == AWAKE) {
    startWinkNow(now);
  }
  float winkAmt = 0;
  if (winkEye != 0) {
    const float u = since(now, winkStart) / (float)winkDur;
    winkAmt = u < 0.35f ? easeIn(u / 0.35f) : 1 - easeOut((u - 0.35f) / 0.65f);
    if (u >= 1) { winkEye = 0; winkAmt = 0; scheduleWink(now); }
  }

  const bool canIdle = mood == MOOD_IDLE && awake == AWAKE && gesture == G_NONE;
  if (!glancing && canIdle && after(now, nextGlanceAt)) {
    glancing = true;
    glanceUntil = now + random(700, 1400);
    targetLookX = (chance(0.5f) ? -1 : 1) * randf(0.45f, 0.85f);
    targetLookY = randf(-0.3f, 0.35f);
  }
  if (glancing && after(now, glanceUntil)) {
    glancing = false;
    targetLookX = 0;
    targetLookY = 0;
    scheduleGlance(now);
  }
  if (canIdle && !glancing && after(now, nextFidgetAt)) {
    pickFidget(now);
    scheduleFidget(now);
  }
  if (canIdle && after(now, nextAutoMoodAt)) {
    const float roll = randf(0, 1);
    if (roll < 0.40f) setMood(MOOD_HAPPY, 2200);
    else if (roll < 0.70f) setMood(MOOD_CURIOUS, 2000);
    else if (roll < 0.82f) setMood(MOOD_EXCITED, 1600);
    else if (roll < 0.90f) setMood(MOOD_DIZZY, 1800);
    else setMood(MOOD_SLEEPY, 2800);
    scheduleAutoMood(now);
  }
  if (gesture != G_NONE && since(now, gestureStart) >= (int32_t)gestureDur) gesture = G_NONE;

  if (after(now, nextMicroAt)) {
    microX = randf(-0.05f, 0.05f);
    microY = randf(-0.04f, 0.04f);
    nextMicroAt = now + random(600, 1600);
  }

  float lx = targetLookX;
  float ly = targetLookY;
  bool directLook = false;
  if (mood == MOOD_DIZZY) {
    swirl += 0.20f;
    lx = sinf(swirl) * 0.85f;
    ly = cosf(swirl) * 0.5f;
    directLook = true;
  } else if (mood == MOOD_ASLEEP) {
    lx = 0;
    ly = 0.3f;
  } else if (!glancing) {
    switch (mood) {
      case MOOD_ANNOYED: lx = 0; ly = -0.3f; break;
      case MOOD_CURIOUS: lx = 0.7f; ly = -0.2f; break;
      case MOOD_SAD: lx = 0.15f * tearSide; ly = 0.5f; break;
      case MOOD_SLEEPY: lx = 0.1f; ly = 0.35f + drowsy * 0.2f; break;
      case MOOD_HAPPY: lx = 0; ly = 0.06f; break;
      case MOOD_LOVE: lx = 0; ly = -0.05f; break;
      case MOOD_SURPRISED:
      case MOOD_EXCITED: lx = 0; ly = 0; break;
      default: break;
    }
  }
  const float gu = gesture != G_NONE ? clampf(since(now, gestureStart) / (float)gestureDur, 0, 1) : 0;
  if (gesture != G_NONE) {
    switch (gesture) {
      case G_GLANCE2:
        if (gu < 0.4f) { lx = gAx; ly = gAy; }
        else if (gu < 0.8f) { lx = gBx; ly = gBy; }
        else { lx = 0; ly = 0; }
        break;
      case G_THINK: lx = -0.45f * gestureSide; ly = -0.55f; break;
      case G_STARE: lx = 0; ly = -0.04f; break;
      case G_SIGH: ly = gu < 0.6f ? 0.35f : 0; lx = 0; break;
      case G_BROW: lx = 0.3f * gestureSide; ly = -0.1f; break;
      case G_EYEROLL: {
        const float a = gu * PI * 1.6f;
        lx = sinf(a) * 0.7f;
        ly = -fabsf(cosf(a)) * 0.6f;
        break;
      }
      default: break;
    }
  }
  if (mood == MOOD_IDLE) { lx += microX; ly += microY; }

  if (directLook) {
    lookX = lerpf(lookX, lx, 0.3f);
    lookY = lerpf(lookY, ly, 0.3f);
    velX = velY = 0;
  } else {
    const float K = 260, D = 14;
    velX += (lx - lookX) * K * dt;
    velY += (ly - lookY) * K * dt;
    const float damp = expf(-D * dt);
    velX *= damp;
    velY *= damp;
    lookX += velX * dt;
    lookY += velY * dt;
  }

  Shape L, R;
  float offYTarget = 0;
  float wantBlush = 0;
  float rate = 0.16f;
  switch (mood) {
    case MOOD_HAPPY:
      L.bot = R.bot = 0.58f; L.top = R.top = 0.04f; L.pupil = R.pupil = 0.92f;
      offYTarget = -2; wantBlush = 1;
      break;
    case MOOD_SLEEPY:
      L.top = R.top = 0.42f + drowsy * 0.3f; L.curve = R.curve = 0.3f;
      L.bot = R.bot = 0.06f; L.pupil = R.pupil = 0.95f;
      break;
    case MOOD_CURIOUS: {
      Shape &wide = lookX >= 0 ? R : L;
      Shape &narrow = lookX >= 0 ? L : R;
      wide.sx = 1.06f; wide.sy = 1.09f; narrow.top = 0.28f;
      break;
    }
    case MOOD_SURPRISED:
      L.sx = R.sx = 1.12f; L.sy = R.sy = 1.16f; L.top = R.top = -0.04f;
      L.pupil = R.pupil = 0.42f; offYTarget = -4; rate = 0.34f;
      break;
    case MOOD_LOVE: L.bot = R.bot = 0.22f; wantBlush = 1; break;
    case MOOD_DIZZY: L.top = R.top = 0.18f; L.bot = R.bot = 0.08f; L.pupil = R.pupil = 0.8f; wantBlush = 0.5f; break;
    case MOOD_ANNOYED: L.top = R.top = 0.40f; L.ang = R.ang = 0.65f; L.pupil = R.pupil = 0.9f; break;
    case MOOD_SAD:
      L.top = R.top = 0.26f; L.ang = R.ang = -0.55f; L.curve = R.curve = 0.15f;
      L.pupil = R.pupil = 1.05f; offYTarget = 3;
      break;
    case MOOD_EXCITED:
      L.sx = R.sx = 1.05f; L.sy = R.sy = 1.05f; L.bot = R.bot = 0.22f;
      L.pupil = R.pupil = 1.25f; wantBlush = 0.8f;
      break;
    case MOOD_ASLEEP: L.top = R.top = 0.3f; L.curve = R.curve = 0.3f; break;
    default: break;
  }
  const float lean = 1 + 0.04f * fabsf(lookX);
  L.sx *= lean;
  R.sx *= lean;

  float gOffX = 0;
  if (gesture != G_NONE) {
    const float env = sinf(PI * gu);
    switch (gesture) {
      case G_THINK: L.top += 0.3f * env; R.top += 0.3f * env; break;
      case G_BROW: {
        Shape &up = gestureSide > 0 ? R : L;
        Shape &down = gestureSide > 0 ? L : R;
        up.sy *= 1 + 0.12f * env; up.sx *= 1 + 0.04f * env; down.top += 0.24f * env;
        break;
      }
      case G_WIGGLE:
        gOffX = sinf(gu * PI * 4) * 3.5f * env;
        L.bot += 0.22f * env; R.bot += 0.22f * env;
        break;
      case G_STARE:
        L.sx *= 1 + 0.03f * env; R.sx *= 1 + 0.03f * env;
        L.sy *= 1 + 0.04f * env; R.sy *= 1 + 0.04f * env;
        break;
      case G_SIGH: {
        const float e = gu < 0.6f ? (gu / 0.6f) : 1 - easeOut((gu - 0.6f) / 0.4f);
        L.top += 0.32f * e; R.top += 0.32f * e; L.bot += 0.06f * e; R.bot += 0.06f * e;
        break;
      }
      case G_STRETCH:
        L.sx *= 1 + 0.07f * env; R.sx *= 1 + 0.07f * env;
        L.sy *= 1 + 0.13f * env; R.sy *= 1 + 0.13f * env;
        L.top -= 0.06f * env; R.top -= 0.06f * env;
        break;
      case G_EYEROLL: L.top += 0.15f * env; R.top += 0.15f * env; break;
      default: break;
    }
  }
  if (mood == MOOD_ANNOYED && since(now, moodEnteredAt) > 500 && gesture == G_NONE && chance(0.004f)) {
    startGesture(G_EYEROLL, 900, now);
  }

  breath = 0.5f + 0.5f * sinf(now / 1600.0f);
  float wobX = 0, wobY = 0;
  if (mood == MOOD_DIZZY) { wobX = sinf(swirl * 0.9f) * 3; wobY = cosf(swirl * 0.7f) * 2; }
  if (mood == MOOD_EXCITED) wobX = sinf(now / 26.0f) * 1.6f;
  if (mood == MOOD_ASLEEP) wobY = sinf(now / 900.0f) * 1.5f;

  shapeL.sx = lerpf(shapeL.sx, L.sx, rate);
  shapeL.sy = lerpf(shapeL.sy, L.sy, rate);
  shapeL.top = lerpf(shapeL.top, L.top, rate);
  shapeL.ang = lerpf(shapeL.ang, L.ang, rate);
  shapeL.curve = lerpf(shapeL.curve, L.curve, rate);
  shapeL.bot = lerpf(shapeL.bot, L.bot, rate);
  shapeL.pupil = lerpf(shapeL.pupil, L.pupil, rate * 1.4f);
  shapeR.sx = lerpf(shapeR.sx, R.sx, rate);
  shapeR.sy = lerpf(shapeR.sy, R.sy, rate);
  shapeR.top = lerpf(shapeR.top, R.top, rate);
  shapeR.ang = lerpf(shapeR.ang, R.ang, rate);
  shapeR.curve = lerpf(shapeR.curve, R.curve, rate);
  shapeR.bot = lerpf(shapeR.bot, R.bot, rate);
  shapeR.pupil = lerpf(shapeR.pupil, R.pupil, rate * 1.4f);

  const float K2 = 220, D2 = 12;
  offVelY += (offYTarget - offY) * K2 * dt;
  offVelY *= expf(-D2 * dt);
  offY += offVelY * dt;
  offX = lerpf(offX, lookX * PAIR_SHIFT_X + gOffX + wobX, 0.25f);

  blush = lerpf(blush, wantBlush, 0.08f);
  heartbeat += dt * 7;
  if (tear > 0) {
    tear += dt * 0.55f;
    if (mood != MOOD_SAD || tear > 1.6f) tear = 0;
  }

  frameBlinkL = clampf(blinkL + (winkEye == 1 ? winkAmt : 0), 0, 1);
  frameBlinkR = clampf(blinkR + (winkEye == 2 ? winkAmt : 0), 0, 1);
  frameOffX = offX;
  frameOffY = offY + lookY * PAIR_SHIFT_Y + wobY + breath * 0.6f;
  const PupilStyle baseStyle = LOOKS[lookIndex].style;
  frameStyle = mood == MOOD_LOVE ? ST_HEART
             : (mood == MOOD_DIZZY && baseStyle != ST_NONE) ? ST_RING
             : baseStyle;
  const uint8_t *lb = LOOKS[lookIndex].blush;
  blushCol[0] = lb[0];
  blushCol[1] = lb[1];
  blushCol[2] = lb[2];
  const float hb = fmaxf(0, sinf(heartbeat));
  frameHeartPulse = 1 + 0.08f * hb * hb * hb;
}

static inline void markDirty(int y0, int y1) {
  y0 = max(0, y0);
  y1 = min(FB_H - 1, y1);
  if (y0 < dirtyTop) dirtyTop = y0;
  if (y1 > dirtyBot) dirtyBot = y1;
}

static inline uint16_t packColor(int r, int g, int b) {
  return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}

// C6 has no FPU — keep the inner loop integer. Integer sqrt for ellipse spans.
static int isqrt(int n) {
  if (n <= 0) return 0;
  unsigned x = (unsigned)n;
  unsigned r = 0;
  unsigned bit = 1u << 30;
  while (bit > x) bit >>= 2;
  while (bit) {
    unsigned t = r + bit;
    r >>= 1;
    if (x >= t) {
      x -= t;
      r += bit;
    }
    bit >>= 2;
  }
  return (int)r;
}

static void fillEllipse(int cx, int cy, int rx, int ry, uint16_t c) {
  if (rx < 1) rx = 1;
  if (ry < 1) ry = 1;
  const int rx2 = rx * rx;
  const int ry2 = ry * ry;
  const int y0 = max(0, cy - ry);
  const int y1 = min(FB_H - 1, cy + ry);
  markDirty(y0, y1);
  for (int y = y0; y <= y1; y++) {
    const int dy = y - cy;
    const int inner = ry2 - dy * dy;
    if (inner < 0) continue;
    const int half = isqrt((int)((int32_t)rx2 * inner / ry2));
    int x0 = cx - half;
    int x1 = cx + half;
    if (x0 < 0) x0 = 0;
    if (x1 >= FB_W) x1 = FB_W - 1;
    uint16_t *row = fb + y * FB_W + x0;
    for (int x = x0; x <= x1; x++) *row++ = c;
  }
}

static void drawBlush(float cx, float cy, float amt, const uint8_t *col) {
  if (amt < 0.03f) return;
  const int R = (int)lroundf(BLUSH_R * amt);
  if (R < 2) return;
  const int a8 = (int)(amt * 140.0f);
  const uint16_t c = packColor(BG[0] + ((col[0] - BG[0]) * a8 >> 8),
                               BG[1] + ((col[1] - BG[1]) * a8 >> 8),
                               BG[2] + ((col[2] - BG[2]) * a8 >> 8));
  fillEllipse((int)lroundf(cx), (int)lroundf(cy), R + 2, R, c);
}

static void drawTear(float cx, float cy, float amt) {
  if (amt <= 0) return;
  const int x = (int)lroundf(cx);
  const int y = (int)lroundf(cy + amt * 14);
  const uint16_t c = packColor(COL_TEAR[0], COL_TEAR[1], COL_TEAR[2]);
  fillEllipse(x, y, 3, 3, c);
  fillEllipse(x, y - 3, 2, 3, c);
}

static bool pupilHit(PupilStyle style, int ddx, int ddy, int pr, int slitAx, int slitAy, int dotR) {
  const int d2 = ddx * ddx + ddy * ddy;
  switch (style) {
    case ST_ROUND:
      return d2 <= pr * pr;
    case ST_DOT:
      return d2 <= dotR * dotR;
    case ST_SLIT: {
      const int32_t ax2 = slitAx * slitAx;
      const int32_t ay2 = slitAy * slitAy;
      return (int64_t)ddx * ddx * ay2 + (int64_t)ddy * ddy * ax2 <= (int64_t)ax2 * ay2;
    }
    case ST_RING: {
      const int mid = (pr * 4) / 5;
      const int thick = pr / 5 + 1;
      const int outer = mid + thick;
      const int inner = mid - thick;
      const int core = pr / 4;
      return (d2 <= outer * outer && d2 >= inner * inner) || d2 <= core * core;
    }
    case ST_STAR: {
      const int ax = ddx < 0 ? -ddx : ddx;
      const int ay = ddy < 0 ? -ddy : ddy;
      return (ax + ay) <= pr || (ax <= pr / 3 && ay <= pr) || (ay <= pr / 3 && ax <= pr);
    }
    case ST_HEART: {
      const int pr2 = (pr * 2) / 5;
      const int lx = ddx + pr / 3, ly = ddy + pr / 5;
      const int rx = ddx - pr / 3, ry = ddy + pr / 5;
      if (lx * lx + ly * ly <= pr2 * pr2) return true;
      if (rx * rx + ry * ry <= pr2 * pr2) return true;
      const int ax = ddx < 0 ? -ddx : ddx;
      return ddy >= 0 && (ax + ddy) < (pr * 4) / 5;
    }
    default:
      return false;
  }
}

static void drawEye(float cxf, float cyf, const Shape &sh, float blink, int side, const Look &look,
                    const uint8_t *pupilCol) {
  const float sy = sh.sy * (1 - 0.94f * blink) * (1 + 0.05f * openPop);
  const int cx = (int)lroundf(cxf);
  const int cy = (int)lroundf(cyf + blink * EYE_B * 0.10f - openPop * 1.5f);
  int rx = (int)lroundf(EYE_A * sh.sx);
  int ry = (int)lroundf(EYE_B * sy);
  if (rx < 4) rx = 4;
  if (ry < 2) ry = 2;

  const int topBaseQ = (int)lroundf((-1.0f + 2.1f * sh.top) * 256.0f);
  const int topCurveQ = (int)lroundf(sh.curve * 0.5f * 256.0f);
  const int botBaseQ = (int)lroundf((1.0f - 1.35f * sh.bot) * 256.0f);
  const int botCurveQ = (int)lroundf(sh.bot * 0.55f * 256.0f);
  const int angQ = (int)lroundf(sh.ang * 0.55f * (float)side * 256.0f);

  const PupilStyle style = frameStyle;
  const bool pupilOn = blink < 0.75f && style != ST_NONE && pupilCol != nullptr;
  int pr = (int)lroundf(style == ST_HEART ? PUPIL_R * 1.3f * frameHeartPulse : PUPIL_R * sh.pupil);
  if (pr < 3) pr = 3;
  const int slitAx = (int)lroundf(clampf(PUPIL_R * (0.36f + (1 - sh.pupil) * 0.9f), PUPIL_R * 0.18f, PUPIL_R * 0.95f));
  const int slitAy = (int)lroundf(PUPIL_R * 1.3f);
  const int dotR = (int)lroundf(PUPIL_R * 0.36f * clampf(sh.pupil, 0.6f, 1.3f));
  const int px = cx + (int)lroundf(lookX * EYE_A * LOOK_TRAVEL_X);
  const int py = cy + (int)lroundf(lookY * EYE_B * LOOK_TRAVEL_Y);
  const bool shineOn = look.shine && (style == ST_ROUND || style == ST_HEART);
  const uint8_t *pc = style == ST_HEART ? COL_HEART : pupilCol;
  const uint16_t sclera = packColor(look.sclera[0], look.sclera[1], look.sclera[2]);
  const uint16_t pcol = pc ? packColor(pc[0], pc[1], pc[2]) : sclera;
  const uint16_t shine = packColor(COL_SHINE[0], COL_SHINE[1], COL_SHINE[2]);
  const int s1x = px - (pr * 36) / 100, s1y = py - (pr * 38) / 100, sr1 = (pr * 30) / 100;
  const int s2x = px + (pr * 26) / 100, s2y = py + (pr * 26) / 100, sr2 = (pr * 13) / 100;
  const int sr1_2 = sr1 * sr1, sr2_2 = sr2 * sr2;

  const int rx2 = rx * rx;
  const int ry2 = ry * ry;
  const int y0 = max(0, cy - ry - 1);
  const int y1 = min(FB_H - 1, cy + ry + 1);
  markDirty(y0, y1);

  for (int y = y0; y <= y1; y++) {
    const int dy = y - cy;
    const int inner = ry2 - dy * dy;
    if (inner < 0) continue;
    const int half = isqrt((int)((int32_t)rx2 * inner / ry2));
    int x0 = cx - half;
    int x1 = cx + half;
    if (x0 < 0) x0 = 0;
    if (x1 >= FB_W) x1 = FB_W - 1;
    const int nyQ = (dy * 256) / ry;
    uint16_t *row = fb + y * FB_W;
    for (int x = x0; x <= x1; x++) {
      const int nxQ = ((x - cx) * 256) / rx;
      const int nx2 = (nxQ * nxQ) >> 8;
      const int topQ = topBaseQ + ((angQ * nxQ) >> 8) - ((topCurveQ * nx2) >> 8);
      if (nyQ < topQ) continue;
      const int botQ = botBaseQ + ((botCurveQ * nx2) >> 8);
      if (nyQ > botQ) continue;
      uint16_t pix = sclera;
      if (pupilOn && pupilHit(style, x - px, y - py, pr, slitAx, slitAy, dotR)) {
        pix = pcol;
        if (shineOn) {
          const int d1 = (x - s1x) * (x - s1x) + (y - s1y) * (y - s1y);
          const int d2 = (x - s2x) * (x - s2x) + (y - s2y) * (y - s2y);
          if (d1 <= sr1_2 || d2 <= sr2_2) pix = shine;
        }
      }
      row[x] = pix;
    }
  }
}

static void drawFace() {
  const Look &look = LOOKS[lookIndex];
  const uint16_t bg = packColor(BG[0], BG[1], BG[2]);
  const int clear0 = max(0, prevTop) * FB_W;
  const int clearN = (min(FB_H - 1, prevBot) + 1) * FB_W;
  uint32_t *p32 = (uint32_t *)(fb + clear0);
  const uint32_t bg2 = ((uint32_t)bg << 16) | bg;
  const int n32 = (clearN - clear0) >> 1;
  for (int i = 0; i < n32; i++) p32[i] = bg2;
  dirtyTop = FB_H;
  dirtyBot = -1;

  drawBlush(EYE_L_X - BLUSH_DX + frameOffX * 0.5f, BLUSH_Y + frameOffY * 0.5f, blush, blushCol);
  drawBlush(EYE_R_X + BLUSH_DX + frameOffX * 0.5f, BLUSH_Y + frameOffY * 0.5f, blush, blushCol);
  if (tear > 0) {
    const float ex = tearSide < 0 ? EYE_L_X - 36 : EYE_R_X + 36;
    drawTear(ex + frameOffX, EYE_Y + EYE_B * 0.55f + frameOffY, tear);
  }
  const uint8_t *pupilL = look.hasPupil ? look.pupil : nullptr;
  const uint8_t *pupilR = look.hasPupil2 ? look.pupil2 : pupilL;
  drawEye(EYE_L_X + frameOffX, EYE_Y + frameOffY, shapeL, frameBlinkL, +1, look, pupilL);
  drawEye(EYE_R_X + frameOffX, EYE_Y + frameOffY, shapeR, frameBlinkR, -1, look, pupilR);

  if (dirtyBot < dirtyTop) { dirtyTop = 0; dirtyBot = 0; }
  const int top = min(prevTop, dirtyTop);
  const int bot = max(prevBot, dirtyBot);
  gfx->draw16bitRGBBitmap(0, top, fb + top * FB_W, FB_W, bot - top + 1);
  prevTop = dirtyTop;
  prevBot = dirtyBot;
}

static void applyBacklight() {
  const int pwm = (int)(C6_BL_PWM * clampf(backlight, 0.25f, 1.0f));
  analogWrite(C6_LCD_BL_PIN, pwm);
}

static void pulseLed(uint32_t now) {
  const Look &L = LOOKS[lookIndex];
  const uint8_t *c = mood == MOOD_LOVE ? COL_HEART : L.sclera;
  const float u = 0.45f + 0.55f * (0.5f + 0.5f * sinf(now / 420.0f));
  const float pop = 1.0f + 0.35f * openPop;
  rgb.setPixelColor(0, rgb.Color((uint8_t)(c[0] * u * pop), (uint8_t)(c[1] * u * pop),
                                 (uint8_t)(c[2] * u * pop)));
  rgb.show();
}

static Mood moodByName(const String &s) {
  for (int i = 0; i < MOOD_COUNT; i++) {
    if (s.equalsIgnoreCase(MOOD_NAMES[i])) return (Mood)i;
  }
  return MOOD_COUNT;
}

static void printStatus() {
  Serial.printf("mood=%s look=%s(%d) awake=%s look=%.2f,%.2f wink=%u\n",
                MOOD_NAMES[mood], LOOKS[lookIndex].name, lookIndex,
                awake == AWAKE ? "awake" : (awake == DROWSY ? "drowsy" : "asleep"),
                lookX, lookY, (unsigned)winkEye);
}

static void printHelp() {
  Serial.println(F("slab-eyes — landscape Arduino eyes on ESP32-C6-LCD-1.47"));
  Serial.println(F("  mood <name> | look <n|next|prev|list> | gaze x y"));
  Serial.println(F("  blink | wink | poke | pokeeye [left|right] | pet | sleep | wake"));
  Serial.println(F("  status | help"));
  Serial.print(F("moods:"));
  for (int i = 0; i < MOOD_COUNT; i++) Serial.printf(" %s", MOOD_NAMES[i]);
  Serial.println();
  Serial.println(F("Always landscape. No IMU — it looks around and winks on its own."));
  Serial.println(F("BOOT short = poke. Hold BOOT = next look."));
}

static void handleSerialLine(String line) {
  line.trim();
  if (!line.length()) return;
  const uint32_t now = millis();
  int sp = line.indexOf(' ');
  String cmd = sp < 0 ? line : line.substring(0, sp);
  String arg = sp < 0 ? "" : line.substring(sp + 1);
  arg.trim();
  cmd.toLowerCase();

  if (cmd == "mood") {
    const Mood m = moodByName(arg);
    if (m == MOOD_COUNT) Serial.println(F("unknown mood"));
    else forceMood(m, 8000);
  } else if (cmd == "look") {
    if (arg == "next") setLook(lookIndex + 1, true);
    else if (arg == "prev") setLook(lookIndex - 1, true);
    else if (arg == "list") {
      for (int i = 0; i < LOOK_COUNT; i++) Serial.printf("%d %s\n", i, LOOKS[i].name);
    } else {
      int found = -1;
      for (int i = 0; i < LOOK_COUNT; i++) {
        if (arg.equalsIgnoreCase(LOOKS[i].name)) found = i;
      }
      setLook(found >= 0 ? found : arg.toInt(), true);
    }
  } else if (cmd == "gaze") {
    int sp2 = arg.indexOf(' ');
    glancing = true;
    glanceUntil = now + 2500;
    targetLookX = clampf(arg.substring(0, sp2 < 0 ? arg.length() : sp2).toFloat(), -1, 1);
    targetLookY = sp2 < 0 ? 0 : clampf(arg.substring(sp2 + 1).toFloat(), -1, 1);
    touched(now);
  } else if (cmd == "blink") {
    startBlink(now, false);
  } else if (cmd == "wink") {
    startWinkNow(now);
  } else if (cmd == "sleep") {
    forceMood(MOOD_ASLEEP, 0);
  } else if (cmd == "wake") {
    touched(now);
  } else if (cmd == "poke") {
    poke();
  } else if (cmd == "pokeeye") {
    pokeEye(arg == "right" ? 1 : -1);
  } else if (cmd == "pet") {
    pet(1);
  } else if (cmd == "status") {
    printStatus();
  } else if (cmd == "help" || cmd == "?") {
    printHelp();
  } else {
    Serial.println(F("? (try help)"));
  }
}

static void pollSerial() {
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      handleSerialLine(g_line);
      g_line = "";
    } else if (g_line.length() < 64) {
      g_line += c;
    }
  }
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const uint32_t now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    setLook(lookIndex + 1, true);
    Serial.printf("look %s\n", LOOKS[(lookIndex + 1) % LOOK_COUNT].name);
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) poke();
    g_bootDown = false;
  }
}

static int defaultLookFromChip() {
  uint64_t mac = ESP.getEfuseMac();
  uint32_t h = 2166136261u;
  for (int i = 0; i < 6; i++) {
    h ^= (uint8_t)(mac >> (8 * i));
    h *= 16777619u;
  }
  return (int)(h % LOOK_COUNT);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println(F("slab-eyes"));

  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  if (!gfx->begin(40000000)) Serial.println(F("LCD init failed"));
  gfx->setRotation(1);
  gfx->fillScreen(packColor(BG[0], BG[1], BG[2]));

  fb = (uint16_t *)malloc(FB_W * FB_H * sizeof(uint16_t));
  if (!fb) {
    Serial.println(F("fb alloc failed"));
    while (true) delay(1000);
  }
  for (int i = 0; i < FB_W * FB_H; i++) fb[i] = packColor(BG[0], BG[1], BG[2]);

  rgb.begin();
  rgb.clear();
  rgb.show();

  prefs.begin("slabeyes", false);
  lookIndex = prefs.getUChar("look", (uint8_t)defaultLookFromChip()) % LOOK_COUNT;
  Serial.printf("look: %s (%d)  heap=%u\n", LOOKS[lookIndex].name, lookIndex, ESP.getFreeHeap());

  randomSeed(esp_random());
  const uint32_t now = millis();
  lastInteraction = now;
  scheduleBlink(now);
  scheduleGlance(now);
  scheduleAutoMood(now);
  scheduleFidget(now);
  scheduleWink(now);
  nextStatsAt = now + 5000;
  printHelp();
}

void loop() {
  static uint32_t lastMs = millis();
  const uint32_t now = millis();
  const float dt = clampf((now - lastMs) / 1000.0f, 0.001f, 0.08f);
  lastMs = now;

  pollBoot();
  pollSerial();
  update(now, dt);
  drawFace();
  applyBacklight();
  if ((frames & 3) == 0) pulseLed(now);

  frames++;
  if (after(now, nextStatsAt)) {
    Serial.printf("fps=%.1f ", frames / 5.0f);
    printStatus();
    frames = 0;
    nextStatsAt = now + 5000;
  }
}

Board and housing

Licensed under CC BY-SA 4.0. Check with the author before commercial use.