Desk buddy robot on an ESP32-C3
Animated robot eyes on a round GC9A01: it blinks and glances about, beams when you stroke it, gets dizzy when rocked and lonely when ignored. The whole build, an ESP32-C3 and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 45 minutes to build and run. Runs in your browser. Free, and no account needed.
Build it as the Desk Buddy Kit: the parts in numbered bags and a booklet that checks every step. Retails for about $22, free on Mokxi.
What you'll learn
- Combining a touch pad, light sensor and motion sensor
- Moods as data: one table of eye shapes
- Easing values toward a target for smooth animation
- Repainting only the pixels that changed
- Using millis() timers instead of delay()
- Difficulty: Advanced
- Time: About 45 minutes
- Board: ESP32-C3-DevKitM-1
- Parts: 7
How it works
Two animated eyes are drawn on a round 240x240 GC9A01 display. Each mood is just a set of numbers for eye size, corner roundness and lid shape. The eyes ease toward the new numbers a quarter of the way each frame, which makes them look alive. The sketch also blinks and glances around by itself.
Three sensors steer the mood. A touch pad on pin 3 makes it happy, and five quick pokes make it angry. A photoresistor module on pin 2 reads dark above 2800, so it gets sleepy and then falls asleep. An MPU6050 on I2C feels rocking, which makes it surprised or dizzy.
A piezo on pin 21 plays a short chirp each time the mood changes. With no touch for 30 seconds it feels lonely.
Parts list
7 parts, plus the jumper wires. They retail for about $22; on Mokxi they're free.
- 1 × ESP32-C3-DevKitM-1In Handheld Console Kit, Desk Buddy Kit.
- 1 × Full-size breadboard
- 1 × Round display, GC9A01In Desk Buddy Kit.
- 1 × Accelerometer and gyroscope, MPU6050In Desk Buddy Kit, Laser Sword Kit, Escape Box Kit.
- 1 × Touch sensor, TTP223In Desk Buddy Kit, Arc Reactor Kit.
- 1 × Photoresistor sensor moduleIn Desk Buddy Kit, Escape Box Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Round display, GC9A01 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Accelerometer and gyroscope, MPU6050 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Touch sensor, TTP223 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Photoresistor sensor module pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Photoresistor sensor module pin AO | ESP32-C3-DevKitM-1 pin 2 |
| Touch sensor, TTP223 pin SIG | ESP32-C3-DevKitM-1 pin 3 |
| Round display, GC9A01 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Accelerometer and gyroscope, MPU6050 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Touch sensor, TTP223 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Photoresistor sensor module pin GND | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker pin 1 | ESP32-C3-DevKitM-1 pin GND |
| Accelerometer and gyroscope, MPU6050 pin SDA | ESP32-C3-DevKitM-1 pin 0 |
| Accelerometer and gyroscope, MPU6050 pin SCL | ESP32-C3-DevKitM-1 pin 1 |
| Round display, GC9A01 pin CS | ESP32-C3-DevKitM-1 pin 10 |
| Round display, GC9A01 pin DC | ESP32-C3-DevKitM-1 pin 4 |
| Round display, GC9A01 pin RES | ESP32-C3-DevKitM-1 pin 5 |
| Round display, GC9A01 pin SDA | ESP32-C3-DevKitM-1 pin 6 |
| Round display, GC9A01 pin SCL | ESP32-C3-DevKitM-1 pin 7 |
| Piezo speaker pin 2 | ESP32-C3-DevKitM-1 pin 21 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Desk buddy: a little robot that lives on your desk and looks at you, with
// two animated eyes on a 1.28 inch GC9A01 round display.
//
// GC9A01 SCL -> 7 SDA -> 6 RES -> 5 DC -> 4 CS -> 10 VCC -> 3V3 GND -> GND
// MPU6050 SDA -> 0 SCL -> 1 VCC -> 3V3 GND -> GND
// TTP223 SIG -> 3 VCC -> 3V3 GND -> GND
// LDR module AO -> 2 VCC -> 3V3 GND -> GND
// Piezo 21 to GND
//
// It blinks, glances about and has moods: happy, sleepy, surprised, angry,
// lonely and dizzy. Stroke the touch pad and it beams at you. Poke it too
// often and it gets cross. Rock it and it gets dizzy. Turn the lights down and
// it nods off. Leave it alone and it starts to feel lonely.
//
// # The eyes
//
// Each eye is a rounded rectangle with two lids: a straight one that comes
// down from the top, tilted for angry or sad, and a curved one that pushes up
// from the bottom to make a happy smile of an eye. Every mood is just a
// different set of numbers for those shapes, and the eyes slide from one set
// to the next a little each frame, which is what makes them look alive.
//
// # Drawing only what changed
//
// The panel is 240x240 pixels and the bus is bit-banged SPI, so repainting it
// all would take a long time. Instead, for every row of each eye, the sketch
// works out which pixels the eye covered last frame and which it covers now,
// and sends only the stretches that changed. A blink is a few dozen short
// lines, not a whole screen.
//
// # Time
//
// LONELY_MS is how long it waits for a stroke before it feels lonely. Half a
// minute keeps the demo lively; a real desk buddy might wait an hour.
#include <SPI.h>
#include <Wire.h>
#include "Adafruit_GFX.h"
#include "Adafruit_GC9A01A.h"
const uint8_t TFT_SCK = 7;
const uint8_t TFT_MOSI = 6;
const uint8_t TFT_RST = 5;
const uint8_t TFT_DC = 4;
const uint8_t TFT_CS = 10;
const uint8_t I2C_SDA = 0;
const uint8_t I2C_SCL = 1;
const uint8_t LDR_PIN = 2;
const uint8_t TOUCH_PIN = 3;
const uint8_t BUZZER = 21;
const uint8_t MPU = 0x68;
const unsigned long LONELY_MS = 30000; // try 3600000 for an hour
const int DARK_LEVEL = 2800; // LDR reading above this is dark
Adafruit_GC9A01A tft(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST);
const uint16_t BLACK = 0x0000;
// ------------------------------------------------------------------ moods
enum Mood : uint8_t { NEUTRAL, HAPPY, SLEEPY, SURPRISED, ANGRY, LONELY, DIZZY, ASLEEP };
const char *const MOOD_NAMES[] = {"neutral", "happy", "sleepy", "surprised", "angry", "lonely", "dizzy", "asleep"};
// What an eye looks like in each mood: width, height, corner radius, how far
// the top lid comes down, its tilt (sixteenths of a pixel per pixel, the inner
// corner lower when positive), how high the happy lid pushes up, and color.
struct Look {
int16_t w, h, r, lid, slope, arch;
uint16_t color;
};
const Look LOOKS[] = {
{56, 66, 18, 0, 0, 0, 0x2EBF}, // neutral: soft cyan
{60, 58, 20, 0, 0, 30, 0x3FFA}, // happy: mint, smiling from below
{56, 66, 18, 34, -3, 0, 0x2C9A}, // sleepy: heavy lids
{66, 80, 28, 0, 0, 0, 0x5F7F}, // surprised: big and round
{56, 62, 14, 22, 9, 0, 0xFA45}, // angry: red, lids slanting in
{50, 56, 18, 16, -7, 0, 0x4C1F}, // lonely: blue, lids slanting out
{44, 44, 22, 0, 0, 0, 0xB71F}, // dizzy: small and round
{58, 8, 4, 0, 0, 0, 0x2C9A}, // asleep: closed
};
Mood mood = NEUTRAL;
Mood shown = NEUTRAL;
unsigned long moodUntil = 0; // a passing mood lasts until then
// ------------------------------------------------------------------ sound
//
// A chirp is a handful of short notes. Sliding the pitch in small steps is
// what makes a piezo sound like a little robot rather than a doorbell.
struct Note {
uint16_t hz;
uint8_t ms;
};
Note song[24];
uint8_t songLen = 0, songAt = 0;
unsigned long noteEnds = 0;
void slide(uint16_t from, uint16_t to, uint8_t steps, uint8_t ms) {
for (uint8_t i = 0; i < steps && songLen < 24; i++) {
song[songLen].hz = from + (int32_t)(to - from) * i / (steps > 1 ? steps - 1 : 1);
song[songLen++].ms = ms;
}
}
void rest(uint8_t ms) {
if (songLen < 24) song[songLen++] = {0, ms};
}
void chirp(Mood m) {
songLen = songAt = 0;
noteEnds = 0;
switch (m) {
case HAPPY: slide(1400, 2600, 6, 18); rest(40); slide(2000, 3000, 4, 18); break;
case SURPRISED: slide(900, 3200, 8, 12); break;
case ANGRY: slide(300, 220, 3, 60); rest(30); slide(300, 220, 3, 60); break;
case LONELY: slide(1800, 700, 8, 40); break;
case DIZZY: for (uint8_t i = 0; i < 4; i++) slide(1200, 1800, 3, 20), slide(1800, 1200, 3, 20); break;
case SLEEPY: slide(1200, 500, 10, 45); break;
default: slide(1600, 2400, 3, 25); break;
}
}
void music(unsigned long now) {
if (songAt >= songLen || now < noteEnds) return;
Note n = song[songAt++];
if (n.hz) tone(BUZZER, n.hz, n.ms);
else noTone(BUZZER);
noteEnds = now + n.ms;
}
// ------------------------------------------------------------------ the eyes
// One eye as it was drawn, or as it is about to be.
struct Eye {
int16_t cx, cy, w, h, r, lid, slope, arch;
uint16_t color;
};
Eye drawn[2]; // what is on the glass now
bool painted = false;
int16_t isqrt(int32_t v) {
if (v <= 0) return 0;
int32_t x = v, y = (x + 1) / 2;
while (y < x) {
x = y;
y = (x + v / x) / 2;
}
return (int16_t)x;
}
// The stretches of row `y` that an eye covers: up to two, [a0, b0) and
// [a1, b1). Two, because the happy lid can split a row in the middle.
uint8_t spans(const Eye &e, int16_t y, int16_t s[4]) {
int16_t top = e.cy - e.h / 2, bot = top + e.h;
if (e.h <= 0 || y < top || y >= bot) return 0;
int16_t r = e.r * 2 > e.h ? e.h / 2 : e.r;
int16_t inset = 0, edge = 0;
if (y < top + r) edge = top + r - y;
else if (y >= bot - r) edge = y - (bot - r) + 1;
if (edge) inset = r - isqrt((int32_t)r * r - (int32_t)edge * edge);
int16_t a = e.cx - e.w / 2 + inset, b = e.cx + e.w / 2 - inset;
// The top lid: keep what is below the line through (cx, top + lid).
int16_t below = y - top - e.lid;
if (e.slope == 0) {
if (below < 0) return 0;
} else if (e.slope > 0) {
int16_t lim = e.cx + below * 16 / e.slope;
if (lim + 1 < b) b = lim + 1;
} else {
int16_t lim = e.cx + below * 16 / e.slope;
if (lim > a) a = lim;
}
if (a >= b) return 0;
// The happy lid: a curve pushing up from the bottom, highest in the middle.
int16_t hw = e.w / 2;
int16_t into = y - (bot - e.arch);
if (e.arch > 0 && into > 0) {
int16_t k = isqrt((int32_t)hw * hw * into / e.arch);
int16_t l = e.cx - k, rr = e.cx + k;
uint8_t n = 0;
if (l > a) s[0] = a, s[1] = l < b ? l : b, n = 1;
if (rr < b) s[n * 2] = rr > a ? rr : a, s[n * 2 + 1] = b, n++;
return n;
}
s[0] = a;
s[1] = b;
return 1;
}
bool inside(const int16_t *s, uint8_t n, int16_t x) {
for (uint8_t i = 0; i < n; i++)
if (x >= s[i * 2] && x < s[i * 2 + 1]) return true;
return false;
}
// Repaint one eye from how it was to how it is: per row, only the stretches
// where the two disagree.
void paint(Eye &was, const Eye &now) {
bool recolor = was.color != now.color;
int16_t y0 = min(was.cy - was.h / 2, now.cy - now.h / 2);
int16_t y1 = max(was.cy - was.h / 2 + was.h, now.cy - now.h / 2 + now.h);
for (int16_t y = max(y0, (int16_t)0); y < min(y1, (int16_t)240); y++) {
int16_t so[4], sn[4], cut[10];
uint8_t no = spans(was, y, so), nn = spans(now, y, sn), nc = 0;
if (!no && !nn) continue;
for (uint8_t i = 0; i < no * 2; i++) cut[nc++] = so[i];
for (uint8_t i = 0; i < nn * 2; i++) cut[nc++] = sn[i];
for (uint8_t i = 1; i < nc; i++) // a handful of numbers: insertion sort
for (uint8_t j = i; j > 0 && cut[j - 1] > cut[j]; j--) {
int16_t t = cut[j];
cut[j] = cut[j - 1];
cut[j - 1] = t;
}
for (uint8_t i = 0; i + 1 < nc; i++) {
int16_t p = cut[i], q = cut[i + 1];
if (p >= q) continue;
bool o = inside(so, no, p), n = inside(sn, nn, p);
if (n && (recolor || !o)) tft.drawFastHLine(p, y, q - p, now.color);
else if (o && !n) tft.drawFastHLine(p, y, q - p, BLACK);
}
}
was = now;
}
// ------------------------------------------------------------------ the face
//
// Everything that moves is held in sixteenths of a pixel and eased toward
// where the mood wants it, a quarter of the way each frame.
struct Face {
int32_t w, h, r, lid, slope, arch, gx, gy;
};
Face cur = {56 << 4, 66 << 4, 18 << 4, 0, 0, 0, 0, 0};
Face want = cur;
int16_t gazeX = 0, gazeY = 0; // where it is looking, in pixels
unsigned long nextGlance = 0, nextBlink = 0, blinkAt = 0;
int16_t tiltX = 0, tiltY = 0; // from the accelerometer, about +-32 per g
void ease(int32_t &v, int32_t target) { v += (target - v) / 4 + (target > v) - (target < v); }
// A sine in 1/64ths for a sixteenth of a turn at a time, for the dizzy swirl.
int8_t wave(uint8_t i) {
static const int8_t Q[5] = {0, 24, 45, 59, 64};
i &= 15;
if (i <= 4) return Q[i];
if (i <= 8) return Q[8 - i];
if (i <= 12) return -Q[i - 8];
return -Q[16 - i];
}
void face(unsigned long now) {
const Look &l = LOOKS[shown];
want.w = l.w << 4;
want.h = l.h << 4;
want.r = l.r << 4;
want.lid = l.lid << 4;
want.slope = l.slope << 4;
want.arch = l.arch << 4;
want.gx = (gazeX + tiltX) << 4;
want.gy = (gazeY + tiltY + (shown == LONELY ? 12 : 0)) << 4;
ease(cur.w, want.w);
ease(cur.h, want.h);
ease(cur.r, want.r);
ease(cur.lid, want.lid);
ease(cur.slope, want.slope);
ease(cur.arch, want.arch);
ease(cur.gx, want.gx);
ease(cur.gy, want.gy);
// A blink squeezes both eyes shut and open again in about a fifth of a second.
int32_t h = cur.h;
if (blinkAt && now - blinkAt < 200) {
int32_t t = now - blinkAt;
int32_t squeeze = t < 100 ? t : 200 - t; // 0..100..0
h = h * (100 - squeeze * 9 / 10) / 100;
}
for (uint8_t i = 0; i < 2; i++) {
int8_t side = i ? 1 : -1;
Eye e;
e.cx = 120 + side * 46 + (cur.gx >> 4);
e.cy = 116 + (cur.gy >> 4);
// The eye on the side it looks toward grows a little, the other shrinks:
// a cheap trick that reads as the head turning.
int16_t grow = (int16_t)((cur.gx >> 4) * side / 6);
e.w = (cur.w >> 4) + grow;
e.h = (int16_t)(h >> 4) + grow;
e.r = cur.r >> 4;
e.lid = cur.lid >> 4;
e.slope = (int16_t)(cur.slope >> 4) * -side; // mirror the tilt
e.arch = cur.arch >> 4;
e.color = LOOKS[shown].color;
if (shown == DIZZY) { // the two eyes swirl in opposite directions
uint8_t ph = now / 60;
e.cx += wave(ph + i * 8) / 5;
e.cy += wave(ph + 4 + i * 8) / 5;
}
if (!painted) drawn[i] = {e.cx, e.cy, 0, 0, 0, 0, 0, 0, e.color};
paint(drawn[i], e);
}
painted = true;
}
// Little extras drawn around the eyes, rubbed out when the mood moves on.
int16_t extraX = -1, extraY = -1;
uint8_t extraKind = 0; // 1 a tear, 2 a z, 3 cheeks
void clearExtra() {
if (extraKind == 1) tft.fillCircle(extraX, extraY, 5, BLACK);
if (extraKind == 2) {
tft.setTextColor(BLACK);
tft.setTextSize(3);
tft.setCursor(extraX, extraY);
tft.print('z');
}
if (extraKind == 3) {
tft.fillRect(extraX - 10, extraY - 4, 20, 8, BLACK);
tft.fillRect(240 - extraX - 10, extraY - 4, 20, 8, BLACK);
}
extraKind = 0;
}
void extras(unsigned long now) {
int16_t eyeBottom = 116 + (int16_t)(cur.gy >> 4) + (int16_t)(cur.h >> 5);
if (shown == LONELY) { // now and then a tear rolls down
uint16_t t = now % 4000;
int16_t y = t < 1600 ? eyeBottom + 6 + t / 40 : -1;
int16_t x = 74 + (int16_t)(cur.gx >> 4);
if (y != extraY || x != extraX || extraKind != 1) {
clearExtra();
if (y > 0 && y < 200) {
tft.fillCircle(x, y, 4, 0x5D9F);
extraX = x, extraY = y, extraKind = 1;
}
}
} else if (shown == ASLEEP) { // a z drifts up
uint16_t t = now % 3000;
int16_t y = 90 - t / 50, x = 170 + t / 150;
if (y != extraY) {
clearExtra();
tft.setTextColor(0x8C71);
tft.setTextSize(3);
tft.setCursor(x, y);
tft.print('z');
extraX = x, extraY = y, extraKind = 2;
}
} else if (shown == HAPPY) { // rosy cheeks under each eye
int16_t x = 74 + (int16_t)(cur.gx >> 4), y = eyeBottom + 14;
if (x != extraX || y != extraY || extraKind != 3) {
clearExtra();
tft.fillRoundRect(x - 10, y - 4, 20, 8, 4, 0xFB2C);
tft.fillRoundRect(240 - x - 10, y - 4, 20, 8, 4, 0xFB2C);
extraX = x, extraY = y, extraKind = 3;
}
} else if (extraKind) {
clearExtra();
}
}
// ------------------------------------------------------------------ the senses
int16_t ax = 0, ay = 0, lastAx = 0, lastAy = 0;
int32_t wobble = 0; // how much it has been rocked lately
bool mpuOk = false;
void mpuWrite(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
void readTilt() {
Wire.beginTransmission(MPU);
Wire.write(0x3B); // ACCEL_XOUT_H: X, Y and Z, two bytes each, high first
Wire.endTransmission(false);
if (Wire.requestFrom(MPU, (uint8_t)4) != 4) return;
ax = (int16_t)(Wire.read() << 8 | Wire.read());
ay = (int16_t)(Wire.read() << 8 | Wire.read());
// At the default +-2 g range, 16384 counts are one g.
tiltX = constrain(-ay / 512, -32, 32);
tiltY = constrain(ax / 512, -24, 24);
wobble += (abs(ax - lastAx) + abs(ay - lastAy)) / 64;
wobble -= wobble / 8;
lastAx = ax;
lastAy = ay;
}
unsigned long lastPet = 0, touchedAt = 0;
uint8_t pokes = 0; // quick touches in a row
unsigned long lastPoke = 0;
bool wasTouched = false, wasDark = false;
unsigned long darkSince = 0;
void feel(Mood m, unsigned long ms, unsigned long now) {
mood = m;
moodUntil = now + ms;
}
void sense(unsigned long now) {
bool touched = digitalRead(TOUCH_PIN) == HIGH;
if (touched && !wasTouched) {
touchedAt = now;
// Quick pokes, each within a second and a half of the last, add up.
pokes = now - lastPoke < 1500 ? pokes + 1 : 1;
lastPoke = now;
Serial.println("touch");
if (pokes >= 5 || (mood == ANGRY && now < moodUntil)) feel(ANGRY, 4000, now);
else feel(HAPPY, 2500, now);
lastPet = now;
}
if (touched && now - touchedAt > 1500 && mood == HAPPY) moodUntil = now + 1500; // a long stroke
wasTouched = touched;
int light = analogRead(LDR_PIN);
bool dark = light > DARK_LEVEL;
if (dark && !wasDark) darkSince = now;
if (!dark && wasDark && shown == ASLEEP) {
feel(SURPRISED, 1200, now);
lastPet = now;
}
wasDark = dark;
if (mpuOk) {
readTilt();
if (wobble > 300 && mood != DIZZY) {
feel(DIZZY, 3500, now);
Serial.println("whoa, dizzy");
} else if (wobble > 120 && now >= moodUntil) {
feel(SURPRISED, 1000, now);
}
}
}
Mood settle(unsigned long now) {
if (now < moodUntil) return mood;
if (wasDark) return now - darkSince > 4000 ? ASLEEP : SLEEPY;
if (now - lastPet > LONELY_MS) return LONELY;
return NEUTRAL;
}
// ------------------------------------------------------------------ setup and loop
void setup() {
Serial.begin(115200);
pinMode(TOUCH_PIN, INPUT);
pinMode(BUZZER, OUTPUT);
tft.begin();
tft.fillScreen(BLACK);
Wire.begin(I2C_SDA, I2C_SCL);
Wire.beginTransmission(MPU);
mpuOk = Wire.endTransmission() == 0;
if (mpuOk) mpuWrite(0x6B, 0); // PWR_MGMT_1: wake up
Serial.println(mpuOk ? "Desk buddy: hello! MPU6050 found" : "Desk buddy: hello! (no MPU6050 on SDA 0, SCL 1)");
lastPet = millis();
nextBlink = millis() + 1500;
feel(HAPPY, 1500, millis());
chirp(HAPPY);
}
unsigned long lastFrame = 0, lastSense = 0;
void loop() {
unsigned long now = millis();
music(now);
if (now - lastSense >= 50) {
lastSense = now;
sense(now);
}
if (now - lastFrame < 30) {
delay(1); // park the core until the next frame is due
return;
}
lastFrame = now;
Mood m = settle(now);
if (m != shown) {
shown = m;
chirp(m);
Serial.print("mood: ");
Serial.println(MOOD_NAMES[m]);
}
// Blink every few seconds, and glance about when nothing else is going on.
if (now >= nextBlink && shown != ASLEEP && shown != DIZZY) {
blinkAt = now;
nextBlink = now + (shown == SLEEPY ? 1200 : 2000 + random(4000));
}
if (now >= nextGlance) {
bool calm = shown == NEUTRAL || shown == LONELY;
gazeX = calm && random(3) ? random(-30, 31) : 0;
gazeY = calm && random(3) ? random(-16, 17) : 0;
if (shown == LONELY) gazeY = abs(gazeY); // looking down at the desk
nextGlance = now + 1200 + random(2600);
}
face(now);
extras(now);
}
Code walkthrough
The key functions of the sketch, and what each one does.
sense sketch.ino
Runs every 50 milliseconds. It reads the touch pad and counts pokes that land within 1.5 seconds of each other. Five in a row, or a poke while already angry, triggers ANGRY. Otherwise a touch means HAPPY. It also reads the light level against DARK_LEVEL and, when the MPU6050 is found, checks how much the board has been rocked.
void sense(unsigned long now) {
bool touched = digitalRead(TOUCH_PIN) == HIGH;
if (touched && !wasTouched) {
touchedAt = now;
// Quick pokes, each within a second and a half of the last, add up.
pokes = now - lastPoke < 1500 ? pokes + 1 : 1;
lastPoke = now;
Serial.println("touch");
if (pokes >= 5 || (mood == ANGRY && now < moodUntil)) feel(ANGRY, 4000, now);
else feel(HAPPY, 2500, now);
lastPet = now;
}
if (touched && now - touchedAt > 1500 && mood == HAPPY) moodUntil = now + 1500; // a long stroke
wasTouched = touched;
int light = analogRead(LDR_PIN);
bool dark = light > DARK_LEVEL;
if (dark && !wasDark) darkSince = now;
if (!dark && wasDark && shown == ASLEEP) {
feel(SURPRISED, 1200, now);
lastPet = now;
}
wasDark = dark;
if (mpuOk) {
readTilt();
if (wobble > 300 && mood != DIZZY) {
feel(DIZZY, 3500, now);
// ... 6 more lines in the editorsettle sketch.ino
Decides which mood to show right now. A passing mood from feel wins until its timer runs out. After that, darkness gives SLEEPY, then ASLEEP after 4 seconds. If nobody has touched it for LONELY_MS, it returns LONELY. Otherwise it is NEUTRAL.
Mood settle(unsigned long now) {
if (now < moodUntil) return mood;
if (wasDark) return now - darkSince > 4000 ? ASLEEP : SLEEPY;
if (now - lastPet > LONELY_MS) return LONELY;
return NEUTRAL;
}paint sketch.ino
Redraws one eye by comparing the old shape to the new one, row by row. It works out which stretches of each row were covered and which are covered now. It then draws only the differences, in the eye color or black. That is why the animation is fast on a bit-banged display bus.
// Repaint one eye from how it was to how it is: per row, only the stretches
// where the two disagree.
void paint(Eye &was, const Eye &now) {
bool recolor = was.color != now.color;
int16_t y0 = min(was.cy - was.h / 2, now.cy - now.h / 2);
int16_t y1 = max(was.cy - was.h / 2 + was.h, now.cy - now.h / 2 + now.h);
for (int16_t y = max(y0, (int16_t)0); y < min(y1, (int16_t)240); y++) {
int16_t so[4], sn[4], cut[10];
uint8_t no = spans(was, y, so), nn = spans(now, y, sn), nc = 0;
if (!no && !nn) continue;
for (uint8_t i = 0; i < no * 2; i++) cut[nc++] = so[i];
for (uint8_t i = 0; i < nn * 2; i++) cut[nc++] = sn[i];
for (uint8_t i = 1; i < nc; i++) // a handful of numbers: insertion sort
for (uint8_t j = i; j > 0 && cut[j - 1] > cut[j]; j--) {
int16_t t = cut[j];
cut[j] = cut[j - 1];
cut[j - 1] = t;
}
for (uint8_t i = 0; i + 1 < nc; i++) {
int16_t p = cut[i], q = cut[i + 1];
if (p >= q) continue;
bool o = inside(so, no, p), n = inside(sn, nn, p);
if (n && (recolor || !o)) tft.drawFastHLine(p, y, q - p, now.color);
else if (o && !n) tft.drawFastHLine(p, y, q - p, BLACK);
}
}
was = now;
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- The eyes never look dizzy when you shake it This project
Check the MPU6050 on SDA pin 0 and SCL pin 1. Without it the sketch prints a note and skips the motion sensing. - It never falls asleep in the dark, or sleeps in the light This project
AdjustDARK_LEVEL. A reading above it counts as dark, and the right value depends on your light sensor module. - The screen stays black This project
Check the display wires: CS on 10, DC on 4, RES on 5, SDA on 6, SCL on 7. - The display stays blank or shows garbage. Round display
Check SCK, MOSI, CS, DC and RST wiring, and use 3.3 V logic. Pick a library that supports the GC9A01 and set its pins to match. Reset the display on start. - Colors look swapped or the image is shifted. Round display
Try the library's RGB or BGR color order option and inversion option. The visible area is a circle, so keep important content near the center. - The sensor is not found or reads all zeros. Accelerometer and gyroscope
Check SDA and SCL (A4 and A5 on an Uno), power, and ground. The I2C address is 0x68, or 0x69 if AD0 is high. The chip starts in sleep mode, so write 0 to the power register PWR_MGMT_1. - The values look wrong or the angles drift. Accelerometer and gyroscope
Raw readings are counts, not g or degrees per second. Divide by the scale for your range (16384 per g at 2 g). Gyro angles drift, so blend with the accelerometer using a filter. - It never triggers, or triggers by itself. Touch sensor
Power it with 2.0 V to 5.5 V and share ground. Keep your hand and the wires away from the pad while it powers up, since it sets its baseline at startup. Avoid long wires.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.