Project NIMO

Project NIMO - Interactive Animated Desktop Companion

Project NIMO - Interactive Animated Desktop Companion

Meet NIMO - a smart, expressive desktop companion that reacts to motion, shows emotions, displays time, weather, and more! Powered by Xiao ESP32-C3 and MPU6050 gyroscope, NIMO brings life to your desk with animated eyes and multiple moods.


What is NIMO?

NIMO is an interactive desktop companion with a 1.3" OLED display that shows animated eyes that follow your movements! When you shake NIMO, it gets dizzy and then automatically becomes angry — all through motion sensing. NIMO also displays:

  • ๐Ÿ• Real-time clock with AM/PM indicator
  • ๐ŸŒ World clock (Jaipur, London, New York)
  • ☀️ Live weather data and 3-day forecast
  • ๐Ÿ˜Š Multiple emotions: Happy, Sad, Angry, Sleepy, Love, Surprised, Suspicious, Dizzy
  • ๐ŸŽญ Touch-sensitive controls to change moods and navigate menus

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/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!


Emotions & Moods

๐Ÿ˜Š Happy
๐Ÿ˜ข Sad
๐Ÿ˜ฒ Surprised
๐Ÿ˜ด Sleepy
๐Ÿ˜  Angry
๐Ÿฅฐ Love
๐Ÿคจ Suspicious
๐Ÿ˜ต Dizzy

Materials Required


3D Printable Files

Download the 3D printable enclosure files for NIMO:

Print these files using JLC3DP's professional 3D printing service:

Order 3D Prints from JLC3DP

Pin Connections

Component Xiao ESP32-C3 Pin Notes
OLED SDA GPIO6 I2C Data
OLED SCL GPIO7 I2C Clock
TTP223 Touch Sensor GPIO4 Input with pullup
MPU6050 SDA GPIO6 (shared with OLED) I2C Data
MPU6050 SCL GPIO7 (shared with OLED) I2C Clock
Slide Switch Battery + to TP4056 IN+ Power ON/OFF
TP4056 OUT+ Xiao 5V Pin Power input
⚠️ Important: The OLED and MPU6050 share the same I2C pins (GPIO6 and GPIO7). This works fine as they have different I2C addresses!

Assembly Steps

1 3D Print the Enclosure

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

2 Install Libraries

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

3 Upload Code

Change WiFi credentials and OpenWeatherMap API key in the code. Select Xiao ESP32-C3 board, correct COM port, and upload.

4 Wire Components

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

5 Mount Inside Enclosure

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

6 Calibrate & Test

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


System Demo Video


Arduino Code

Project NIMO - Complete Code
// ==================================================
// NIMO - PERFECT EYES & SMALL MOUTH + MPU6050 MOTION
// Features: FAST 3D eye tracking, Shake = Dizzy → 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>

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

// ==================================================
// YOUR CREDENTIALS - EDIT THESE
// ==================================================
const char* WIFI_SSID = "Your_WiFi_Name";
const char* WIFI_PASSWORD = "Your_Password";
const char* OPENWEATHER_API_KEY = "your_api_key_here";
const char* CITY = "Your_City";
const char* COUNTRY_CODE = "CC";
const char* TIMEZONE = "IST-5:30";  // Change for your timezone

// ==================================================
// 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;

// Current orientation
float currentRoll = 0, currentPitch = 0;

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

// Post-dizzy anger (AUTOMATIC)
bool isAngry = false;
unsigned long angryEndTime = 0;
const int ANGRY_DURATION = 2000;

// Fast eye tracking
float targetPupilX = 0, targetPupilY = 0;
float currentPupilX = 0, currentPupilY = 0;
const float SMOOTH_FACTOR = 0.4;  // Fast response

// Previous values for shake detection
float lastAccelX = 0, lastAccelY = 0, lastAccelZ = 0;
float prevRoll = 0, prevPitch = 0;

// ==================================================
// WEATHER ICONS (Bitmap arrays - truncated for length)
// ==================================================
// Full bitmap arrays included in the complete code download

// ==================================================
// 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;

// MOODS
#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;

// Weather Data
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;
  float w, h;
  float targetX, targetY, targetW, targetH;
  float pupilX, pupilY;
  float targetPupilX, targetPupilY;
  float velX, velY, velW, velH;
  float pVelX, pVelY;
  float k = 0.15;
  float d = 0.65;
  float pk = 0.2;
  float pd = 0.6;
  bool blinking;
  unsigned long lastBlink;
  unsigned long 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();
  
  // Get calibrated angles
  currentRoll = mpu6050.getAngleX() - calibAngleX;
  currentPitch = mpu6050.getAngleY() - calibAngleY;
  
  // Get acceleration for shake detection
  float accelX = mpu6050.getAccX();
  float accelY = mpu6050.getAccY();
  float accelZ = mpu6050.getAccZ();
  
  // Calculate shake intensity
  float deltaX = fabs(accelX - lastAccelX);
  float deltaY = fabs(accelY - lastAccelY);
  float deltaZ = fabs(accelZ - lastAccelZ);
  shakeIntensity = (deltaX + deltaY + deltaZ) * 10;
  
  // SHAKE DETECTION - Trigger dizzy
  if (shakeIntensity > 20.0 && !isShaking && !isAngry && currentPage == 0) {
    isShaking = true;
    shakeStartTime = millis();
    shakeEndTime = millis() + SHAKE_DURATION;
    currentMood = MOOD_DIZZY;
  }
  
  // End dizzy and start ANGRY automatically
  if (isShaking && millis() >= shakeEndTime) {
    isShaking = false;
    isAngry = true;
    angryEndTime = millis() + ANGRY_DURATION;
    currentMood = MOOD_ANGRY;
  }
  
  // End angry state
  if (isAngry && millis() >= angryEndTime) {
    isAngry = false;
    currentMood = weatherMood;
  }
  
  // EYE TRACKING - Follow tilt IMMEDIATELY
  if (!isShaking && !isAngry && currentPage == 0) {
    // Map tilt to pupil position (more sensitive)
    float rawX = currentRoll / 5.0;
    float rawY = currentPitch / 5.0;
    
    targetPupilX = constrain(rawX, -12, 12);
    targetPupilY = constrain(rawY, -10, 10);
    
    // FAST smooth follow
    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;
    
    // Head follows tilt
    leftEye.targetX = 28 + (currentRoll / 15);
    leftEye.targetY = 18 + (currentPitch / 15);
    rightEye.targetX = 80 + (currentRoll / 15);
    rightEye.targetY = 18 + (currentPitch / 15);
  }
  
  // Store values
  lastAccelX = accelX;
  lastAccelY = accelY;
  lastAccelZ = accelZ;
  prevRoll = currentRoll;
  prevPitch = currentPitch;
}

// ==================================================
// WEATHER FUNCTIONS (truncated for length)
// ==================================================
// Full functions included in complete code

// ==================================================
// 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 (truncated for length)
// ==================================================
// Full drawing functions included in complete code

// ==================================================
// 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();
  
  // Initialize MPU6050
  mpu6050.begin();
  calibrateMPU6050();
  
  // Initialize eyes
  leftEye.init(28, 18, 32, 32);
  rightEye.init(80, 18, 32, 32);
  
  // Connect to WiFi
  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 eye tracking
  • MPU6050 Motion Detection: Real-time tilt tracking and shake detection
  • Automatic Mood Transitions: Shake → Dizzy → Angry automatically
  • Touch Navigation: Tap to change screens, long press to change moods
  • WiFi Weather: Live weather data and 3-day forecast
  • NTP Time Sync: Accurate time with multiple time zones
  • Blinking Animation: Natural random blinking intervals

Setup Instructions:

  • Change WIFI_SSID and WIFI_PASSWORD to your WiFi credentials
  • Get a free API key from OpenWeatherMap and replace OPENWEATHER_API_KEY
  • Set your CITY and COUNTRY_CODE (e.g., "London", "UK")
  • Set your TIMEZONE (e.g., "IST-5:30" for India, "EST-5" for New York)
  • Install required libraries: Adafruit_GFX, Adafruit_SH110X, MPU6050_tockn, Arduino_JSON
  • Select "Xiao ESP32-C3" board and upload!

Technical 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

Applications

๐ŸŽจ Desktop Companion
Fun animated buddy for your desk
๐ŸŽ“ Educational
Learn about sensors, displays, and animations
๐Ÿค– Robotics
Base for expressive robot projects
๐ŸŽ Gift Project
Unique handmade gift for tech lovers

๐ŸŽ“ Design Your Own PCBs with Altium

For designing professional PCBs like custom versions of NIMO, I use Altium — a platform that makes electronics design easier, faster, and more connected.

For students, they have Altium Student Lab. Just sign up with your university email for free access to step-by-step PCB courses and industry-recognized certifications.

๐Ÿ‘‰ Start building your future with Altium

๐Ÿค– Note: Keep NIMO still during the first 5 seconds for calibration! After that, tilt it to see the eyes follow your movement. Shake it to trigger the dizzy → angry reaction. Tap the touch sensor to navigate between screens!

Comments

Popular posts from this blog

Solar Tracking System

Arduino Code

Arduino Code Car Parking System