Skip to main content

Project NIMO

Project NIMO - Interactive Animated Desktop Companion
Interactive Desktop Companion

Project
NIMO

A smart, expressive desktop buddy that reacts to motion, shows emotions, and displays live time and weather. Powered by Xiao ESP32-C3 and MPU6050 gyroscope. Animated eyes that actually follow you.

10
Emotions
WiFi
Live Weather
8-10h
Battery Life
6-DOF
Motion Sensing

Overview

What is NIMO?

NIMO is an interactive desktop companion with a 1.3" OLED display that shows animated eyes which follow your movements. Shake it — it goes dizzy, then automatically gets angry. It also displays:

  • Real-time clock with AM/PM indicator
  • World clock — Jaipur, London, New York
  • Live weather data and 3-day forecast via OpenWeatherMap
  • 10 unique emotions: Happy, Sad, Angry, Sleepy, Love, Surprised, Suspicious, Dizzy, and more
  • Touch-sensitive controls to change moods and navigate screens

Mechanics

How NIMO Works

Motion Tracking: The MPU6050 gyroscope detects tilt and shake. When you tilt NIMO, the eyes follow your movement smoothly. When shaken, NIMO enters a dizzy state with spinning eyes and stars, then automatically becomes angry!

Touch Control: The TTP223 touch sensor lets you navigate menus:

  • Tap once: Switch between screens — Eyes, Clock, Weather
  • Long press: Change mood or toggle between clock and forecast views

WiFi Features: Connects to WiFi to fetch current weather, 3-day forecast, and sync accurate time via NTP.

Battery Powered: The TP4056 charging module and 1000mAh LiPo battery make NIMO fully portable.


Personality System

Emotions & Moods

😊Happy
😢Sad
😲Surprised
😴Sleepy
😠Angry
🥰Love
🤨Suspicious
😵Dizzy

Bill of Materials

Materials Required

ComponentBuy
Xiao ESP32-C3Buy Now
1.3" OLED Display (SH1106)Buy Now
TP4056 Charging ModuleBuy Now
3.7V LiPo Battery 1000mAhBuy Now
TTP223 Touch SensorBuy Now
MPU6050 GyroscopeBuy Now
4mm Slide SwitchBuy Now
Jumper WiresBuy Now

3D Printable Parts

3D Printable Files

Download the enclosure STL files for NIMO's rounded body with screen cutout. Print at home or order a professional print through JLC3DP.

Order via JLC3DP

Wiring

Pin Connections

ComponentESP32-C3 PinNotes
OLED SDAGPIO6I2C Data
OLED SCLGPIO7I2C Clock
TTP223 Touch SensorGPIO4Input with pullup
MPU6050 SDAGPIO6Shared with OLED
MPU6050 SCLGPIO7Shared with OLED
Slide SwitchBattery + to TP4056 IN+Power ON/OFF
TP4056 OUT+Xiao 5V PinPower input
⚠️

Note: The OLED and MPU6050 share the same I2C pins (GPIO6 and GPIO7). This works fine as they have different I2C addresses.


Build Guide

Assembly Steps

01
3D Print the Enclosure

Download and 3D print the enclosure files. NIMO has a cute rounded body with a screen cutout on the front.

02
Install Libraries

Install required libraries in Arduino IDE: Adafruit_GFX, Adafruit_SH110X, MPU6050_tockn, Arduino_JSON, and WiFi/HTTPClient (built-in).

03
Upload Code

Set your WiFi credentials and OpenWeatherMap API key. Select Xiao ESP32-C3 board, correct COM port, and upload.

04
Wire Components

Solder wires according to the pin table. Connect OLED, MPU6050, touch sensor, battery, and charging module.

05
Mount Inside Enclosure

Secure all components inside the 3D printed enclosure. Ensure the OLED faces the cutout and touch sensor is accessible.

06
Calibrate & Test

Power on NIMO and keep it still during calibration (first 5 seconds). Then tilt, shake, and tap to test all features.


Demo

System Demo Video


Firmware

Arduino Code

NIMO.ino — Arduino C++
// ==================================================
// NIMO - PERFECT EYES & SMALL MOUTH + MPU6050 MOTION
// Features: FAST 3D eye tracking, Shake = Dizzy then Angry automatically
// ==================================================

#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <Arduino_JSON.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include "time.h"
#include <math.h>
#include <Fonts/FreeSansBold18pt7b.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSans9pt7b.h>

#include <MPU6050_tockn.h>
MPU6050 mpu6050(Wire);

// ==================================================
// YOUR CREDENTIALS - EDIT THESE
// ==================================================
const char* WIFI_SSID = "Galaxy";
const char* WIFI_PASSWORD = "rdj02480";
const char* OPENWEATHER_API_KEY = "d9c460c0d5b726695a5553885ac10025";
const char* CITY = "NYC";
const char* COUNTRY_CODE = "USA";
const char* TIMEZONE = "IST-5:30";

// ==================================================
// HARDWARE CONFIGURATION
// ==================================================
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SDA_PIN 6
#define SCL_PIN 7
#define TOUCH_PIN 4

Adafruit_SH1106G display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// ==================================================
// MPU6050 MOTION VARIABLES
// ==================================================
#define CALIBRATION_SAMPLES 100
float calibAngleX = 0, calibAngleY = 0, calibAngleZ = 0;
bool isCalibrated = false;

float currentRoll = 0, currentPitch = 0;

float shakeIntensity = 0;
bool isShaking = false;
unsigned long shakeStartTime = 0;
unsigned long shakeEndTime = 0;
const int SHAKE_DURATION = 1500;

bool isAngry = false;
unsigned long angryEndTime = 0;
const int ANGRY_DURATION = 2000;

float targetPupilX = 0, targetPupilY = 0;
float currentPupilX = 0, currentPupilY = 0;
const float SMOOTH_FACTOR = 0.4;

float lastAccelX = 0, lastAccelY = 0, lastAccelZ = 0;
float prevRoll = 0, prevPitch = 0;

// ==================================================
// WEATHER ICONS
// ==================================================
const unsigned char bmp_clear[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x00,0x01,0x80,0x00,0x00,0x01,0x80,0x00,0x00,0x01,0x80,0x00,
  0x00,0x00,0x00,0x00,0x01,0x03,0xc0,0x80,0x00,0x0f,0xf0,0x00,0x00,0x3f,0xfc,0x00,
  0x00,0x7f,0xfe,0x00,0x00,0xff,0xff,0x00,0x06,0xff,0xff,0x60,0x06,0xff,0xff,0x60,
  0x06,0xff,0xff,0x60,0x00,0xff,0xff,0x00,0x3e,0xff,0xff,0x7c,0x3e,0xff,0xff,0x7c,
  0x3e,0xff,0xff,0x7c,0x00,0xff,0xff,0x00,0x06,0xff,0xff,0x60,0x06,0xff,0xff,0x60,
  0x06,0xff,0xff,0x60,0x00,0xff,0xff,0x00,0x00,0x7f,0xfe,0x00,0x00,0x3f,0xfc,0x00,
  0x01,0x0f,0xf0,0x80,0x00,0x03,0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x80,0x00,
  0x00,0x01,0x80,0x00,0x00,0x01,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char bmp_clouds[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0xe0,0x00,
  0x00,0x0f,0xf8,0x00,0x00,0x1f,0xfc,0x00,0x00,0x3f,0xfe,0x00,0x00,0x3f,0xff,0x00,
  0x00,0x7f,0xff,0x80,0x00,0xff,0xff,0xc0,0x00,0xff,0xff,0xe0,0x01,0xff,0xff,0xf0,
  0x03,0xff,0xff,0xf8,0x07,0xff,0xff,0xfc,0x07,0xff,0xff,0xfc,0x0f,0xff,0xff,0xfe,
  0x0f,0xff,0xff,0xfe,0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,
  0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,0x0f,0xff,0xff,0xfe,
  0x07,0xff,0xff,0xfc,0x03,0xff,0xff,0xf8,0x00,0xff,0xff,0xe0,0x00,0x3f,0xff,0x80,
  0x00,0x0f,0xfe,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char bmp_rain[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x00,0x03,0xe0,0x00,0x00,0x0f,0xf8,0x00,0x00,0x1f,0xfc,0x00,
  0x00,0x3f,0xfe,0x00,0x00,0x7f,0xff,0x80,0x00,0xff,0xff,0xc0,0x01,0xff,0xff,0xf0,
  0x03,0xff,0xff,0xf8,0x07,0xff,0xff,0xfc,0x0f,0xff,0xff,0xfe,0x1f,0xff,0xff,0xff,
  0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,0x1f,0xff,0xff,0xff,0x0f,0xff,0xff,0xfe,
  0x07,0xff,0xff,0xfc,0x03,0xff,0xff,0xf8,0x00,0xff,0xff,0xe0,0x00,0x3f,0xff,0x80,
  0x00,0x0f,0xfe,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x0c,0x00,0x00,0x60,0x0c,0x00,
  0x00,0xe0,0x1c,0x00,0x00,0xc0,0x18,0x00,0x03,0x80,0x70,0x00,0x03,0x80,0x70,0x00,
  0x03,0x00,0x60,0x00,0x02,0x00,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char mini_sun[] PROGMEM = {
  0x00,0x00,0x01,0x80,0x00,0x00,0x10,0x08,0x04,0x20,0x03,0xc0,0x27,0xe4,0x07,0xe0,
  0x07,0xe0,0x27,0xe4,0x03,0xc0,0x04,0x20,0x10,0x08,0x00,0x00,0x01,0x80,0x00,0x00
};
const unsigned char mini_cloud[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x01,0xc0,0x07,0xe0,0x0f,0xf0,0x1f,0xf8,0x1f,0xf8,0x3f,0xfc,
  0x3f,0xfc,0x7f,0xfe,0x3f,0xfe,0x1f,0xfc,0x0f,0xf0,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char mini_rain[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x01,0xc0,0x07,0xe0,0x0f,0xf0,0x1f,0xf8,0x1f,0xf8,0x3f,0xfc,
  0x3f,0xfc,0x7f,0xfe,0x3f,0xfe,0x1f,0xfc,0x00,0x00,0x44,0x44,0x22,0x22,0x11,0x11
};
const unsigned char bmp_tiny_drop[] PROGMEM = { 0x10,0x38,0x7c,0xfe,0xfe,0x7c,0x38,0x00 };
const unsigned char bmp_dizzy_stars[] PROGMEM = {
  0x08,0x20,0x14,0x50,0x22,0x88,0x41,0x04,0x82,0x02,0x41,0x04,0x22,0x88,0x14,0x50,
  0x08,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char bmp_angry_mark[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x08,0x00,0x1c,0x00,0x3e,0x00,0x7f,0x00,0x3e,0x00,0x1c,0x00,
  0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};

// ==================================================
// EMOTION PARTICLES
// ==================================================
const unsigned char bmp_heart[] PROGMEM = {
  0x00,0x00,0x0c,0x60,0x1e,0xf0,0x3f,0xf8,0x7f,0xfc,0x7f,0xfc,0x7f,0xfc,0x3f,0xf8,
  0x1f,0xf0,0x0f,0xe0,0x07,0xc0,0x03,0x80,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
const unsigned char bmp_zzz[] PROGMEM = {
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x3c,0x00,0x0c,0x00,0x18,0x00,0x30,0x00,0x7e,
  0x00,0x00,0x3c,0x00,0x0c,0x00,0x18,0x00,0x30,0x00,0x7c,0x00,0x00,0x00,0x00,0x00
};

// ==================================================
// GLOBALS
// ==================================================
int currentPage = 0;
int subPage = 0;
int tapCounter = 0;
unsigned long lastTapTime = 0;
bool lastPinState = false;
unsigned long pressStartTime = 0;
bool isLongPressHandled = false;
const unsigned long LONG_PRESS_TIME = 800;
const unsigned long DOUBLE_TAP_DELAY = 300;

#define MOOD_NORMAL     0
#define MOOD_HAPPY      1
#define MOOD_SURPRISED  2
#define MOOD_SLEEPY     3
#define MOOD_ANGRY      4
#define MOOD_SAD        5
#define MOOD_EXCITED    6
#define MOOD_LOVE       7
#define MOOD_SUSPICIOUS 8
#define MOOD_DIZZY      9

int currentMood = MOOD_HAPPY;
int weatherMood = MOOD_HAPPY;

float temperature = 0.0;
float feelsLike = 0.0;
int humidity = 0;
String weatherMain = "Clear";
String weatherDesc = "Sunny";
unsigned long lastWeatherUpdate = 0;

struct ForecastDay {
  String dayName;
  int temp;
  String iconType;
};
ForecastDay fcast[3];

// ==================================================
// EYE PHYSICS ENGINE
// ==================================================
struct Eye {
  float x, y, w, h;
  float targetX, targetY, targetW, targetH;
  float pupilX, pupilY, targetPupilX, targetPupilY;
  float velX, velY, velW, velH, pVelX, pVelY;
  float k = 0.15, d = 0.65, pk = 0.2, pd = 0.6;
  bool blinking;
  unsigned long lastBlink, nextBlinkTime;

  void init(float _x, float _y, float _w, float _h) {
    x = targetX = _x; y = targetY = _y;
    w = targetW = _w; h = targetH = _h;
    pupilX = targetPupilX = 0; pupilY = targetPupilY = 0;
    nextBlinkTime = millis() + random(1000, 4000);
  }
  void update() {
    float ax = (targetX - x) * k; float ay = (targetY - y) * k;
    float aw = (targetW - w) * k; float ah = (targetH - h) * k;
    velX = (velX + ax) * d; velY = (velY + ay) * d;
    velW = (velW + aw) * d; velH = (velH + ah) * d;
    x += velX; y += velY; w += velW; h += velH;
    float pax = (targetPupilX - pupilX) * pk;
    float pay = (targetPupilY - pupilY) * pk;
    pVelX = (pVelX + pax) * pd; pVelY = (pVelY + pay) * pd;
    pupilX += pVelX; pupilY += pVelY;
  }
};

Eye leftEye, rightEye;
unsigned long lastSaccade = 0;
unsigned long saccadeInterval = 3000;
float breathVal = 0;

// ==================================================
// MPU6050 FUNCTIONS
// ==================================================
void calibrateMPU6050() {
  display.clearDisplay();
  display.setFont(NULL);
  display.setCursor(20, 20);
  display.print("Calibrating...");
  display.setCursor(10, 35);
  display.print("Keep device STILL");
  display.display();

  float sumX = 0, sumY = 0, sumZ = 0;
  for (int i = 0; i < CALIBRATION_SAMPLES; i++) {
    mpu6050.update();
    sumX += mpu6050.getAngleX();
    sumY += mpu6050.getAngleY();
    sumZ += mpu6050.getAngleZ();
    delay(10);
  }
  calibAngleX = sumX / CALIBRATION_SAMPLES;
  calibAngleY = sumY / CALIBRATION_SAMPLES;
  calibAngleZ = sumZ / CALIBRATION_SAMPLES;
  isCalibrated = true;

  display.clearDisplay();
  display.setCursor(25, 30);
  display.print("Calibrated!");
  display.display();
  delay(1000);
}

void updateMotion() {
  if (!isCalibrated) return;
  mpu6050.update();

  currentRoll  = mpu6050.getAngleX() - calibAngleX;
  currentPitch = mpu6050.getAngleY() - calibAngleY;

  float accelX = mpu6050.getAccX();
  float accelY = mpu6050.getAccY();
  float accelZ = mpu6050.getAccZ();

  float deltaX = fabs(accelX - lastAccelX);
  float deltaY = fabs(accelY - lastAccelY);
  float deltaZ = fabs(accelZ - lastAccelZ);
  shakeIntensity = (deltaX + deltaY + deltaZ) * 10;

  if (shakeIntensity > 20.0 && !isShaking && !isAngry && currentPage == 0) {
    isShaking = true;
    shakeStartTime = millis();
    shakeEndTime = millis() + SHAKE_DURATION;
    currentMood = MOOD_DIZZY;
  }
  if (isShaking && millis() >= shakeEndTime) {
    isShaking = false;
    isAngry = true;
    angryEndTime = millis() + ANGRY_DURATION;
    currentMood = MOOD_ANGRY;
  }
  if (isAngry && millis() >= angryEndTime) {
    isAngry = false;
    currentMood = weatherMood;
  }

  if (!isShaking && !isAngry && currentPage == 0) {
    float rawX = currentRoll / 5.0;
    float rawY = currentPitch / 5.0;
    targetPupilX = constrain(rawX, -12, 12);
    targetPupilY = constrain(rawY, -10, 10);
    currentPupilX = currentPupilX * 0.6 + targetPupilX * 0.4;
    currentPupilY = currentPupilY * 0.6 + targetPupilY * 0.4;
    leftEye.targetPupilX  = currentPupilX;
    leftEye.targetPupilY  = currentPupilY;
    rightEye.targetPupilX = currentPupilX;
    rightEye.targetPupilY = currentPupilY;
    leftEye.targetX  = 28 + (currentRoll / 15);
    leftEye.targetY  = 18 + (currentPitch / 15);
    rightEye.targetX = 80 + (currentRoll / 15);
    rightEye.targetY = 18 + (currentPitch / 15);
  }

  lastAccelX = accelX; lastAccelY = accelY; lastAccelZ = accelZ;
  prevRoll = currentRoll;  prevPitch = currentPitch;
}

// ==================================================
// WEATHER FUNCTIONS
// ==================================================
const unsigned char* getBigIcon(String w) {
  if (w == "Clear")                      return bmp_clear;
  if (w == "Clouds")                     return bmp_clouds;
  if (w == "Rain" || w == "Drizzle")     return bmp_rain;
  return bmp_clouds;
}

const unsigned char* getMiniIcon(String w) {
  if (w == "Clear")                                        return mini_sun;
  if (w == "Rain" || w == "Drizzle" || w == "Thunderstorm") return mini_rain;
  return mini_cloud;
}

void updateWeatherMood() {
  if (weatherMain == "Clear")                           weatherMood = MOOD_HAPPY;
  else if (weatherMain == "Rain" || weatherMain == "Drizzle") weatherMood = MOOD_SAD;
  else if (weatherMain == "Thunderstorm")               weatherMood = MOOD_SURPRISED;
  else if (weatherMain == "Clouds")                     weatherMood = MOOD_NORMAL;
  else if (temperature > 35)                           weatherMood = MOOD_ANGRY;
  else if (temperature < 10)                           weatherMood = MOOD_SLEEPY;
  else                                                  weatherMood = MOOD_NORMAL;

  if (!isShaking && !isAngry) currentMood = weatherMood;
}

void getWeatherAndForecast() {
  if (WiFi.status() != WL_CONNECTED) return;

  HTTPClient http;
  String url = "http://api.openweathermap.org/data/2.5/weather?q="
    + String(CITY) + "," + String(COUNTRY_CODE)
    + "&appid=" + String(OPENWEATHER_API_KEY) + "&units=metric";
  http.begin(url);
  int httpCode = http.GET();
  if (httpCode == 200) {
    String payload = http.getString();
    JSONVar myObject = JSON.parse(payload);
    if (JSON.typeof(myObject) != "undefined") {
      temperature = double(myObject["main"]["temp"]);
      feelsLike   = double(myObject["main"]["feels_like"]);
      humidity    = int(myObject["main"]["humidity"]);
      weatherMain = (const char*)myObject["weather"][0]["main"];
      weatherDesc = (const char*)myObject["weather"][0]["description"];
      if (weatherDesc.length() > 0) weatherDesc[0] = toupper(weatherDesc[0]);
      updateWeatherMood();
    }
  }
  http.end();

  url = "http://api.openweathermap.org/data/2.5/forecast?q="
    + String(CITY) + "," + String(COUNTRY_CODE)
    + "&appid=" + String(OPENWEATHER_API_KEY) + "&units=metric";
  http.begin(url);
  httpCode = http.GET();
  if (httpCode == 200) {
    String payload = http.getString();
    JSONVar fo = JSON.parse(payload);
    if (JSON.typeof(fo) != "undefined") {
      struct tm t;
      getLocalTime(&t);
      int today = t.tm_wday;
      const char* days[] = { "SUN","MON","TUE","WED","THU","FRI","SAT" };
      int indices[3] = { 7, 15, 23 };
      for (int i = 0; i < 3; i++) {
        int idx = indices[i];
        fcast[i].temp     = (int)double(fo["list"][idx]["main"]["temp"]);
        fcast[i].iconType = (const char*)fo["list"][idx]["weather"][0]["main"];
        int nextDayIndex  = (today + i + 1) % 7;
        fcast[i].dayName  = days[nextDayIndex];
      }
    }
  }
  http.end();
}

// ==================================================
// TOUCH HANDLER
// ==================================================
void handleTouch() {
  bool currentPinState = digitalRead(TOUCH_PIN);
  unsigned long now = millis();

  if (currentPinState && !lastPinState) {
    pressStartTime = now;
    isLongPressHandled = false;
  } else if (currentPinState && lastPinState) {
    if ((now - pressStartTime > LONG_PRESS_TIME) && !isLongPressHandled) {
      if (currentPage == 0 && !isShaking && !isAngry) {
        currentMood++;
        if (currentMood > MOOD_SUSPICIOUS) currentMood = 0;
        weatherMood = currentMood;
      } else if (currentPage == 1) {
        subPage = (subPage == 1) ? 0 : 1;
      } else if (currentPage == 2) {
        subPage = (subPage == 2) ? 0 : 2;
      }
      isLongPressHandled = true;
    }
  } else if (!currentPinState && lastPinState) {
    if ((now - pressStartTime < LONG_PRESS_TIME) && !isLongPressHandled) {
      tapCounter++;
      lastTapTime = now;
    }
  }
  lastPinState = currentPinState;

  if (tapCounter > 0) {
    if (now - lastTapTime > DOUBLE_TAP_DELAY) {
      if (tapCounter == 1) {
        if (subPage != 0) {
          subPage = 0;
        } else {
          currentPage++;
          if (currentPage > 2) currentPage = 0;
        }
      }
      tapCounter = 0;
    }
  }
}

// ==================================================
// DRAW FUNCTIONS
// ==================================================
void drawDizzyEye(Eye& e, bool isLeft) {
  int ix = (int)e.x, iy = (int)e.y, iw = (int)e.w, ih = (int)e.h;
  int cx = ix + iw / 2, cy = iy + ih / 2;

  display.fillCircle(cx, cy, iw / 2, SH110X_WHITE);

  unsigned long now = millis();
  float angle = (now - shakeStartTime) * 0.025;
  int radius = iw / 3;
  int pupilX = cx + cos(angle) * radius;
  int pupilY = cy + sin(angle) * radius;
  display.fillCircle(pupilX, pupilY, 4, SH110X_BLACK);
  display.fillCircle(pupilX + 2, pupilY - 2, 1, SH110X_WHITE);

  int pupilX2 = cx - cos(angle) * radius;
  int pupilY2 = cy - sin(angle) * radius;
  display.fillCircle(pupilX2, pupilY2, 3, SH110X_BLACK);

  int starOffset = (now / 80) % 4;
  display.drawBitmap(6 - starOffset, 0, bmp_dizzy_stars, 16, 16, SH110X_WHITE);
  display.drawBitmap(106 + starOffset, 0, bmp_dizzy_stars, 16, 16, SH110X_WHITE);
}

void drawAngryEye(Eye& e, bool isLeft) {
  int ix = (int)e.x, iy = (int)e.y, iw = (int)e.w, ih = (int)e.h;

  display.fillRoundRect(ix, iy, iw, ih, 8, SH110X_WHITE);
  int cx = ix + iw / 2, cy = iy + ih / 2;
  int pupilSize = iw / 2.5;
  int px = cx - 2, py = cy;
  display.fillRoundRect(px - pupilSize/2, py - pupilSize/2, pupilSize, pupilSize, pupilSize/2, SH110X_BLACK);

  if (isLeft) {
    for (int i = 0; i < 8; i++) display.drawLine(ix-2, iy-2+i, ix+iw+2, iy+6+i, SH110X_BLACK);
    display.drawBitmap(ix - 12, iy - 6, bmp_angry_mark, 16, 16, SH110X_WHITE);
  } else {
    for (int i = 0; i < 8; i++) display.drawLine(ix-2, iy+6+i, ix+iw+2, iy-2+i, SH110X_BLACK);
    display.drawBitmap(ix + iw - 4, iy - 6, bmp_angry_mark, 16, 16, SH110X_WHITE);
  }
  for (int i = 0; i < 3; i++) display.drawLine(ix+2+i*4, iy+ih-2, ix+6+i*4, iy+ih-6, SH110X_WHITE);
}

void drawNormalEye(Eye& e, bool isLeft) {
  int ix = (int)e.x, iy = (int)e.y, iw = (int)e.w, ih = (int)e.h;
  int r = (iw < 20) ? 3 : 8;
  display.fillRoundRect(ix, iy, iw, ih, r, SH110X_WHITE);

  int cx = ix + iw / 2, cy = iy + ih / 2;
  int pw = iw / 2.2, ph = ih / 2.2;
  int px = cx + (int)e.pupilX - pw / 2;
  int py = cy + (int)e.pupilY - ph / 2;
  if (px < ix) px = ix;
  if (px + pw > ix + iw) px = ix + iw - pw;
  if (py < iy) py = iy;
  if (py + ph > iy + ih) py = iy + ih - ph;
  display.fillRoundRect(px, py, pw, ph, r / 2, SH110X_BLACK);
  if (iw > 15 && ih > 15) display.fillCircle(px + pw - 4, py + 4, 2, SH110X_WHITE);

  if (currentMood == MOOD_HAPPY || currentMood == MOOD_LOVE)
    display.fillRect(ix, iy + ih - 10, iw, 12, SH110X_BLACK);
  else if (currentMood == MOOD_SLEEPY)
    display.fillRect(ix, iy, iw, ih / 2, SH110X_BLACK);
  else if (currentMood == MOOD_SAD) {
    if (isLeft)
      for (int i = 0; i < 8; i++) display.drawLine(ix, iy+i, ix+iw, iy+4+i, SH110X_BLACK);
    else
      for (int i = 0; i < 8; i++) display.drawLine(ix, iy+4+i, ix+iw, iy+i, SH110X_BLACK);
  }
}

void drawMouth() {
  int mx = 64, my = 54;

  if (currentMood == MOOD_DIZZY && isShaking) {
    unsigned long now = millis();
    for (int i = -8; i <= 8; i++) {
      int y = my + sin(i * 0.8 + now * 0.03) * 4;
      display.drawPixel(mx + i, y, SH110X_WHITE);
    }
    return;
  }
  if (currentMood == MOOD_ANGRY && isAngry) {
    display.fillRect(mx - 8, my - 2, 16, 5, SH110X_BLACK);
    for (int i = -5; i <= 5; i += 3) display.drawLine(mx+i, my-2, mx+i, my+2, SH110X_WHITE);
    return;
  }

  switch (currentMood) {
    case MOOD_HAPPY:
      for (int i = -6; i <= 6; i++) {
        int y = my - (i * i / 16);
        if (y < my + 2) display.drawPixel(mx + i, y, SH110X_WHITE);
      }
      break;
    case MOOD_SAD:
      for (int i = -6; i <= 6; i++) {
        int y = my + 2 + (i * i / 16);
        display.drawPixel(mx + i, y, SH110X_WHITE);
      }
      break;
    case MOOD_SURPRISED:
      display.fillCircle(mx, my + 1, 3, SH110X_BLACK);
      display.drawCircle(mx, my + 1, 3, SH110X_WHITE);
      break;
    case MOOD_SLEEPY:
      display.drawLine(mx - 4, my, mx + 4, my, SH110X_WHITE);
      break;
    case MOOD_LOVE:
      display.drawBitmap(mx - 6, my - 2, bmp_heart, 12, 12, SH110X_WHITE);
      break;
    default:
      display.drawLine(mx - 4, my, mx + 4, my, SH110X_WHITE);
      break;
  }
}

void updateEyePhysics() {
  unsigned long now = millis();
  breathVal = sin(now / 800.0) * 1.5;

  int blinkDelay = (isShaking || isAngry) ? 60 : 120;
  int blinkMin   = (isShaking || isAngry) ? 300 : 2000;
  int blinkMax   = (isShaking || isAngry) ? 800 : 6000;

  if (now > leftEye.nextBlinkTime) {
    leftEye.blinking = rightEye.blinking = true;
    leftEye.lastBlink = now;
    leftEye.nextBlinkTime = now + random(blinkMin, blinkMax);
  }
  if (leftEye.blinking) {
    leftEye.targetH = rightEye.targetH = 2;
    if (now - leftEye.lastBlink > blinkDelay) {
      leftEye.blinking = rightEye.blinking = false;
      leftEye.targetH  = rightEye.targetH  = 32;
    }
  }
  leftEye.update();
  rightEye.update();
}

void drawEmoPage() {
  updateEyePhysics();
  if (currentMood == MOOD_DIZZY && isShaking) {
    drawDizzyEye(leftEye, true);
    drawDizzyEye(rightEye, false);
  } else if (currentMood == MOOD_ANGRY && isAngry) {
    drawAngryEye(leftEye, true);
    drawAngryEye(rightEye, false);
  } else {
    drawNormalEye(leftEye, true);
    drawNormalEye(rightEye, false);
  }
  drawMouth();

  if (!isShaking && !isAngry) {
    if (currentMood == MOOD_LOVE)
      display.drawBitmap(56, 0, bmp_heart, 16, 16, SH110X_WHITE);
    else if (currentMood == MOOD_SLEEPY)
      display.drawBitmap(110, 0, bmp_zzz, 16, 16, SH110X_WHITE);
  }
}

void drawClockPage() {
  struct tm t;
  if (!getLocalTime(&t)) {
    display.setFont(NULL);
    display.setCursor(30, 30);
    display.print("Syncing...");
    return;
  }
  String ampm = (t.tm_hour >= 12) ? "PM" : "AM";
  int h12 = t.tm_hour % 12;
  if (h12 == 0) h12 = 12;

  display.setTextColor(SH110X_WHITE);
  display.setFont(NULL);
  display.setCursor(114, 0);
  display.print(ampm);

  display.setFont(&FreeSansBold18pt7b);
  char timeStr[6];
  sprintf(timeStr, "%02d:%02d", h12, t.tm_min);
  int16_t x1, y1; uint16_t w, h;
  display.getTextBounds(timeStr, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 42);
  display.print(timeStr);

  display.setFont(&FreeSans9pt7b);
  char dateStr[20];
  strftime(dateStr, 20, "%a, %b %d", &t);
  display.getTextBounds(dateStr, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 60);
  display.print(dateStr);
}

void drawWorldClockPage() {
  struct tm t;
  if (!getLocalTime(&t)) return;
  time_t now; time(&now);
  time_t londonEpoch   = now;
  time_t newyorkEpoch  = now - (4 * 3600);
  struct tm* localtm   = &t;
  struct tm* londontm  = gmtime(&londonEpoch);
  struct tm* nytm      = gmtime(&newyorkEpoch);

  display.fillRect(0, 0, 128, 14, SH110X_WHITE);
  display.setFont(NULL);
  display.setTextColor(SH110X_BLACK);
  String title = "WORLD CLOCK";
  int16_t x1, y1; uint16_t w, h;
  display.getTextBounds(title, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 4);
  display.print(title);
  display.setTextColor(SH110X_WHITE);

  display.drawLine(42, 16, 42, 63, SH110X_WHITE);
  display.drawLine(85, 16, 85, 63, SH110X_WHITE);
  display.setFont(NULL);
  display.setCursor(12, 18); display.print("JPR");
  display.setCursor(52, 18); display.print("LDN");
  display.setCursor(94, 18); display.print("NYC");

  char jStr[10]; sprintf(jStr, "%02d:%02d", localtm->tm_hour, localtm->tm_min);
  display.getTextBounds(jStr, 0, 0, &x1, &y1, &w, &h);
  display.setCursor(21 - w/2, 48); display.print(jStr);

  char lStr[10]; sprintf(lStr, "%02d:%02d", londontm->tm_hour, londontm->tm_min);
  display.getTextBounds(lStr, 0, 0, &x1, &y1, &w, &h);
  display.setCursor(64 - w/2, 48); display.print(lStr);

  char nStr[10]; sprintf(nStr, "%02d:%02d", nytm->tm_hour, nytm->tm_min);
  display.getTextBounds(nStr, 0, 0, &x1, &y1, &w, &h);
  display.setCursor(106 - w/2, 48); display.print(nStr);
}

void drawWeatherPage() {
  if (WiFi.status() != WL_CONNECTED) {
    display.setFont(NULL); display.setCursor(0, 0); display.print("No WiFi");
    return;
  }
  display.drawBitmap(96, 0, getBigIcon(weatherMain), 32, 32, SH110X_WHITE);
  display.setFont(&FreeSansBold9pt7b);
  String c = CITY; c.toUpperCase();
  display.setCursor(0, 14);
  if (c.length() > 9) c = c.substring(0, 8) + ".";
  display.print(c);

  display.setFont(&FreeSansBold18pt7b);
  int tempInt = (int)temperature;
  display.setCursor(0, 48);
  display.print(tempInt);

  int16_t x1, y1; uint16_t w, h;
  display.getTextBounds(String(tempInt).c_str(), 0, 48, &x1, &y1, &w, &h);
  display.fillCircle(x1 + w + 5, 26, 4, SH110X_WHITE);

  display.setFont(NULL);
  display.drawBitmap(88, 32, bmp_tiny_drop, 8, 8, SH110X_WHITE);
  display.setCursor(100, 32); display.print(humidity); display.print("%");
  display.setCursor(88, 45); display.print("~"); display.print((int)feelsLike);
  display.drawLine(0, 52, 128, 52, SH110X_WHITE);
  display.setCursor(0, 55);
  String shortDesc = weatherDesc;
  if (shortDesc.length() > 15) shortDesc = shortDesc.substring(0, 13) + "..";
  display.print(shortDesc);
}

void drawForecastPage() {
  display.fillRect(0, 0, 128, 14, SH110X_WHITE);
  display.setFont(NULL);
  display.setTextColor(SH110X_BLACK);
  String title = "3-DAY FORECAST";
  int16_t x1, y1; uint16_t w, h;
  display.getTextBounds(title, 0, 0, &x1, &y1, &w, &h);
  display.setCursor((SCREEN_WIDTH - w) / 2, 4);
  display.print(title);
  display.setTextColor(SH110X_WHITE);

  display.drawLine(42, 16, 42, 63, SH110X_WHITE);
  display.drawLine(85, 16, 85, 63, SH110X_WHITE);

  for (int i = 0; i < 3; i++) {
    int xStart = i * 43, centerX = xStart + 21;
    display.setFont(NULL);
    String d = fcast[i].dayName;
    if (d == "") d = "WAIT";
    display.setCursor(centerX - (d.length() * 3), 18);
    display.print(d);
    display.drawBitmap(centerX - 8, 26, getMiniIcon(fcast[i].iconType), 16, 16, SH110X_WHITE);
    display.setFont(&FreeSansBold9pt7b);
    display.getTextBounds(String(fcast[i].temp).c_str(), 0, 0, &x1, &y1, &w, &h);
    display.setCursor(centerX - w/2 - 2, 58);
    display.print(fcast[i].temp);
    display.fillCircle(centerX + w/2 + 1, 50, 2, SH110X_WHITE);
  }
}

void playBootAnimation() {
  display.setTextColor(SH110X_WHITE);
  int cx = 64, cy = 32;
  for (int r = 0; r < 80; r += 4) {
    display.clearDisplay(); display.fillCircle(cx, cy, r, SH110X_WHITE); display.display(); delay(8);
  }
  for (int r = 0; r < 80; r += 4) {
    display.clearDisplay();
    display.fillCircle(cx, cy, 80, SH110X_WHITE);
    display.fillCircle(cx, cy, r, SH110X_BLACK);
    display.display(); delay(8);
  }
  display.setFont(&FreeSansBold9pt7b);
  String bootText = "N I M O";
  int16_t x1, y1; uint16_t w, h;
  display.getTextBounds(bootText, 0, 0, &x1, &y1, &w, &h);
  display.clearDisplay();
  display.setCursor((SCREEN_WIDTH - w) / 2, 36);
  display.print(bootText);
  display.display();
  delay(2000);
}

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

  Wire.begin(SDA_PIN, SCL_PIN);
  pinMode(TOUCH_PIN, INPUT_PULLUP);

  display.begin(0x3C, true);
  display.setTextColor(SH110X_WHITE);
  display.clearDisplay();

  mpu6050.begin();
  calibrateMPU6050();

  leftEye.init(28, 18, 32, 32);
  rightEye.init(80, 18, 32, 32);

  playBootAnimation();

  display.clearDisplay();
  display.setFont(NULL);
  display.setCursor(20, 30);
  display.print("Connecting WiFi");
  display.display();

  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  unsigned long wifiStart = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - wifiStart < 15000)) {
    delay(500);
    display.print(".");
    display.display();
  }
  if (WiFi.status() == WL_CONNECTED) {
    display.clearDisplay(); display.setCursor(25, 30); display.print("WiFi Connected!"); display.display(); delay(1000);
    configTime(0, 0, "pool.ntp.org");
    setenv("TZ", TIMEZONE, 1); tzset();
    getWeatherAndForecast();
    lastWeatherUpdate = millis();
  } else {
    display.clearDisplay(); display.setCursor(15, 30); display.print("WiFi Failed!"); display.display(); delay(2000);
  }
}

// ==================================================
// LOOP
// ==================================================
void loop() {
  handleTouch();
  updateMotion();

  if (millis() - lastWeatherUpdate > 600000 && WiFi.status() == WL_CONNECTED) {
    getWeatherAndForecast();
    lastWeatherUpdate = millis();
  }

  display.clearDisplay();

  if (currentPage == 0)      drawEmoPage();
  else if (currentPage == 1) { if (subPage == 1) drawWorldClockPage(); else drawClockPage(); }
  else if (currentPage == 2) { if (subPage == 2) drawForecastPage();   else drawWeatherPage(); }

  display.display();
  delay(20);
}

Code Features:

  • Physics-Based Eye Movement — smooth acceleration/deceleration for realistic tracking
  • MPU6050 Motion Detection — real-time tilt tracking and shake detection
  • Automatic Mood Transitions — Shake then Dizzy then Angry automatically
  • Touch Navigation — tap to change screens, long press to change moods
  • WiFi Weather — live weather data and 3-day forecast from OpenWeatherMap
  • NTP Time Sync — accurate time with multiple time zones
  • Natural Blinking — random blink intervals for lifelike animation

Setup Instructions:

  • Change WIFI_SSID and WIFI_PASSWORD to your credentials
  • Get a free API key from OpenWeatherMap and replace OPENWEATHER_API_KEY
  • Set your CITY and COUNTRY_CODE
  • Set your TIMEZONE — e.g. IST-5:30 for India
  • Install: Adafruit_GFX, Adafruit_SH110X, MPU6050_tockn, Arduino_JSON
  • Select Xiao ESP32-C3 board in Arduino IDE and upload

Technical Specs

Specifications

  • Microcontroller: Seeed Studio Xiao ESP32-C3 (RISC-V, 160MHz)
  • Display: 1.3" OLED SH1106 (128x64 pixels)
  • Motion Sensor: MPU6050 (6-axis gyro + accelerometer)
  • Touch Sensor: TTP223 Capacitive Touch Module
  • Battery: 3.7V LiPo 1000mAh with TP4056 charger
  • Power Consumption: ~100mA (screen on, WiFi connected)
  • Battery Life: 8-10 hours per charge
  • WiFi: 2.4GHz 802.11 b/g/n
  • Emotions: 10 different moods with unique eye/mouth animations

Use Cases

Applications

🎨

Desktop Companion

Fun animated buddy for your work or study desk

🎓

Educational

Learn sensors, displays, animations, and WiFi IoT

🤖

Robotics Base

Foundation for expressive robot face projects

🎁

Gift Project

Unique handmade gift for tech enthusiasts


Design Your Own PCBs with Altium

For designing professional PCBs like custom versions of NIMO, Altium makes electronics design faster and more connected.

Students can access Altium Student Lab — sign up with your university email for free step-by-step PCB courses and industry-recognized certifications.

Start building your future with Altium
🤖

Tip: Keep NIMO still during the first 5 seconds for gyroscope calibration. After that, tilt it to see the eyes follow your movement. Shake it to trigger the dizzy then angry reaction. Tap the touch sensor to navigate between screens!

Neon Toybox Theme — Built for makers

Comments

Popular posts from this blog

Solar Tracking System

Dual-Axis Solar Tracking System | Arduino SimpleCircuits Arduino Solar DIY Renewable Energy Project Dual-Axis Solar Tracking System Build a high-performance solar tracker that follows the sun in real-time. Four LDR sensors, an Arduino UNO, and two servo motors — boosting energy capture by up to 40% versus fixed mounts. 40% More Efficient 4 LDR Sensors 2 Servo Axes <300mA Power Draw Finished Build System Overview How the System Works The tracker reads the sky through four LDR sensors placed around the panel, computes intensity deltas, and drives two SG90 servos in a real-time closed loop — keeping the panel perpendicular to sunlight from sunrise to sunset. ...

Arduino Code Car Parking System

 // Created by Simple Circuits  #include <Wire.h>  #include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27,16,2);    #include <Servo.h>  Servo myservo; int IR1 = 2; int IR2 = 3; int Slot = 4;           //Total number of parking Slots int flag1 = 0; int flag2 = 0; void setup() {   Serial.begin(9600);      lcd.init(); //initialize the lcd     lcd.backlight(); //open the backlight     pinMode(IR1, INPUT); pinMode(IR2, INPUT);    myservo.attach(4); myservo.write(100); lcd.setCursor (0,0); lcd.print("     ARDUINO    "); lcd.setCursor (0,1); lcd.print(" PARKING SYSTEM "); delay (2000); lcd.clear();   } void loop(){  if(digitalRead (IR1) == LOW && flag1==0){ if(Slot>0){flag1=1; if(flag2==0){myservo.write(0); Slot = Slot-1;} }else{ lcd.setCursor (0,0); lcd.print("    SORRY :(    ");   lc...

Arduino Code

 //define Pins #include <Servo.h> Servo servo; int trigPin = 11; int echoPin = 12; // defines variables long duration; int distance; void setup()  {   servo.attach(13);   servo.write(180);  delay(2000);    // Sets the trigPin as an Output pinMode(trigPin, OUTPUT); // Sets the echoPin as an Input  pinMode(echoPin, INPUT); } void loop()  { // Clears the trigPin digitalWrite(trigPin, LOW); delayMicroseconds(2); // Sets the trigPin on HIGH state for 10 micro seconds digitalWrite(trigPin, HIGH); delayMicroseconds(10); digitalWrite(trigPin, LOW); // Reads the echoPin, returns the sound wave travel time in microseconds duration = pulseIn(echoPin, HIGH); // Calculating the distance distance= duration*0.034/2; // Prints the distance on the Serial Monitor Serial.print("Distance: "); Serial.println(distance); if ( distance <= 25   ) // Change Distance according to Ultrasonic Sensor Placement  { servo.write(180); delay(3000); ...