Robot Eyes Keychain

A pocket-sized pair of animated eyes that laugh, wonder, scowl and sulk when you press a button.

  • No servos
  • 4 wires
  • Button is built in
  • Around an hour to build
XIAO ESP32-C3
Now: IdleNext: Happy

A live copy of the real behavior. Each emotion lasts 2.5 seconds, then the eyes go back to idle.

Parts

  • ×1
    Seeed XIAO ESP32-C3Has a BOOT button on the board that we use as the trigger.
  • ×1
    1.3 inch I2C OLED (SH1106, 128×64)Four pins: VCC, GND, SDA, SCL.
  • ×4
    Jumper wiresOr thin wire to solder for the final keychain.
  • ×1
    USB-C data cableFor power and uploading. An optional small LiPo can go on the battery pads for portable use.

Wire it up

Four wires to the OLED. The trigger button is already on the board.

VCC GND SDA SCL 1.3 in OLED check your module's labels USB-C B R XIAO ESP32-C3 D0 D1 D2 D3 D4 D5 D6 5V GND 3V3 D10 D9 D8 D7 B BOOT is on the board It reads as GPIO9. Nothing to wire for the trigger. R is RESET.
OLED pin order varies between modules. Match by the printed labels.
  • 3V3 to VCC
  • GND to GND
  • D4 (GPIO6) to SDA
  • D5 (GPIO7) to SCL
Power the OLED from 3V3, and don't hold BOOT while plugging inHolding BOOT at power-up puts the chip in download mode. Pressing it while the sketch runs is fine.

Software setup

  1. Add ESP32 support

    In Arduino IDE 2.x, open Boards Manager and install esp32 by Espressif. If it isn't listed, add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional boards manager URLs.

  2. Install three libraries

    Adafruit GFX Library, Adafruit SH110X and FluxGarage RoboEyes. If RoboEyes isn't in the Library Manager, add its ZIP from GitHub.

  3. Pick the board

    Board: XIAO_ESP32C3. Set USB CDC On Boot to Enabled so the Serial Monitor works.

Build

  1. Wire it

    Connect the four wires from the diagram, then plug in the USB-C cable.

  2. Upload the sketch

    Paste the full code below and upload. If it can't connect, hold BOOT, tap RESET, release BOOT, and try again.

  3. Try it

    The eyes blink and wander. Each BOOT press plays happy, curious, annoyed, then sad, for 2.5 seconds each before returning to idle.

Screen blank or "SH1106 not found"?Recheck VCC, GND, SDA and SCL. Some modules use I2C address 0x3D instead of 0x3C; change I2C_ADDRESS to match.

The full sketch

Paste this into the Arduino IDE. Change EMOTION_HOLD_MS to adjust how long each emotion plays.

robot_eyes_keychain.ino
/*
  Robot Eyes Keychain Companion — Seeed XIAO ESP32-C3 + 1.3" SH1106 OLED
  -------------------------------------------------------------------------
  - Uses FluxGarage_RoboEyes library for smooth autonomous eye movements.
  - Native 1.3" SH1106 support via Adafruit_SH110X template.
  - Onboard BOOT Button (GPIO9) cycles expressions and triggers laugh animations.
  - Every emotion plays for EMOTION_HOLD_MS (2.5 s), then returns to idle.
  - No servos or external wiring needed.

  Wiring (XIAO ESP32C3):
    OLED VCC -> 3V3       OLED SDA -> D4 (GPIO6)
    OLED GND -> GND       OLED SCL -> D5 (GPIO7)
    Trigger  -> Onboard BOOT Button (GPIO9)
*/

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <FluxGarage_RoboEyes.h>

// ===================== PINS (XIAO ESP32-C3) =====================
#define OLED_SDA          D4  // GPIO6
#define OLED_SCL          D5  // GPIO7
#define BUTTON_PIN        9   // Onboard BOOT Button (GPIO9)

// ===================== DISPLAY =====================
#define SCREEN_WIDTH      128
#define SCREEN_HEIGHT     64
#define OLED_RESET        -1
#define I2C_ADDRESS       0x3C  // Default for 1.3" SH1106

// ===================== TIMING =====================
#define EMOTION_HOLD_MS   2500  // how long each emotion plays before returning to idle

Adafruit_SH1106G display = Adafruit_SH1106G(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
RoboEyes<Adafruit_SH1106G> roboEyes(display);

// ===================== EMOTIONS =====================
enum Emotion {
  EMO_IDLE,
  EMO_HAPPY,
  EMO_CURIOUS,
  EMO_ANNOYED,
  EMO_SAD
};

Emotion currentEmotion = EMO_IDLE;
unsigned long emotionStartTime = 0;

// Button cycle order (independent of the auto-return timeout)
const Emotion emotionCycle[] = { EMO_HAPPY, EMO_CURIOUS, EMO_ANNOYED, EMO_SAD };
const uint8_t CYCLE_LEN = sizeof(emotionCycle) / sizeof(emotionCycle[0]);
uint8_t cycleIdx = 0;

// Button State
unsigned long lastButtonPress = 0;
bool lastButtonState = HIGH;

// ===================== EMOTION SYSTEM =====================
void setEmotion(Emotion e) {
  currentEmotion = e;
  emotionStartTime = millis();

  // Clear anything a previous emotion may have left on
  roboEyes.setHFlicker(OFF, 0);

  switch (e) {
    case EMO_IDLE:
      roboEyes.setMood(DEFAULT);
      roboEyes.setPosition(DEFAULT);
      roboEyes.setIdleMode(ON, 2, 2);
      roboEyes.setCuriosity(ON);
      break;

    case EMO_HAPPY:
      roboEyes.setMood(HAPPY);
      roboEyes.setIdleMode(OFF, 0, 0);
      roboEyes.anim_laugh();
      break;

    case EMO_CURIOUS:
      roboEyes.setMood(DEFAULT);
      roboEyes.setCuriosity(ON);
      roboEyes.setPosition(NE);
      roboEyes.setIdleMode(OFF, 0, 0);
      break;

    case EMO_ANNOYED:
      roboEyes.setMood(ANGRY);
      roboEyes.setIdleMode(OFF, 0, 0);
      roboEyes.setHFlicker(ON, 2);
      break;

    case EMO_SAD:
      roboEyes.setMood(TIRED);
      roboEyes.setIdleMode(OFF, 0, 0);
      roboEyes.setPosition(S);
      break;
  }
}

void updateEmotionLogic() {
  // Auto-return to IDLE after EMOTION_HOLD_MS
  if (currentEmotion != EMO_IDLE && millis() - emotionStartTime >= EMOTION_HOLD_MS) {
    setEmotion(EMO_IDLE);
  }
}

// ===================== BUTTON =====================
void handleButton() {
  bool state = digitalRead(BUTTON_PIN);

  // Active LOW detection on BOOT button (GPIO9)
  if (state == LOW && lastButtonState == HIGH && (millis() - lastButtonPress > 250)) {
    lastButtonPress = millis();

    // Each press advances to the next emotion, even after returning to idle
    setEmotion(emotionCycle[cycleIdx]);
    cycleIdx = (cycleIdx + 1) % CYCLE_LEN;
  }
  lastButtonState = state;
}

// ===================== SETUP =====================
void setup() {
  Serial.begin(115200);

  // Configure onboard button
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  // I2C bus setup for XIAO ESP32-C3
  Wire.begin(OLED_SDA, OLED_SCL);
  Wire.setClock(400000); // 400 kHz fast I2C mode

  // Initialize OLED (SH1106)
  if (!display.begin(I2C_ADDRESS, true)) {
    Serial.println(F("SH1106 not found - check wiring/address (try 0x3D)"));
    while (true);
  }

  // Initialize RoboEyes
  roboEyes.begin(SCREEN_WIDTH, SCREEN_HEIGHT, 60);
  roboEyes.setAutoblinker(ON, 3, 2);
  roboEyes.setIdleMode(ON, 2, 2);
  roboEyes.setMood(DEFAULT);
  roboEyes.setCuriosity(ON);

  setEmotion(EMO_IDLE);
}

// ===================== MAIN LOOP =====================
void loop() {
  roboEyes.update();
  handleButton();
  updateEmotionLogic();
}