click to listen ⸜(。˃ ᵕ ˂ )⸝*.゚♫

Smart object · A focus companion that clips on

Desk Buddy.

A tiny robot that clips onto your laptop and keeps you in deep focus.

The problem

Focus is a fight with yourself.

A text. An email. A call. A notification you swear you'll ignore, or no reason at all, just the itch to scroll. One glance, and the twenty minutes you'd set aside are gone, just like that.

Comic: a desk worker resisting the urge to check their phone, then telling themselves to focus.
Hand drawn explainer of the Pomodoro technique: set a 25 minute timer, no interruptions, focus, reward.
The idea

We built it on the Pomodoro technique.

25 minutes of focus, a short break, repeat. That's the whole technique. So we thought, why not make it fun? What if we prototyped a little buddy built around it, sitting right there on your desk?

What we used to prototype it
The Desk Buddy breadboard prototype with its components labelled.
Battery
Breadboard
Buzzer
ESP32
Wires
OLED screen
desk_buddy.ino
/*
Focus Bot — Cute Watching Eyes
Hardware: Feather ESP32 + HC-SR04 + SSD1306 OLED
 
Big cute eyes on the OLED that watch you, blink,
go X_X when you leave, hearts when you finish,
and sleepy on break.
*/
 
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
 
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const bool USE_AP = false;
const char* AP_SSID = "FocusBot";
const char* AP_PASS = "focus1234";
 
#define TRIG_PIN 12
#define ECHO_PIN 13
#define SW 128
#define SH 64
Adafruit_SSD1306 oled(SW, SH, &Wire, -1);
 
// Timer
unsigned long focusDur = 25*60, breakDur = 5*60, longBreakDur = 15*60;
int sessionsForLong = 4;
enum State { IDLE, FOCUS, BREAK_TIME, PAUSED };
State state = IDLE, stateBeforePause = IDLE;
unsigned long remaining = 0, lastTick = 0;
int completed = 0;
 
// Presence (ultrasonic)
bool present = true, prevPresent = true;
unsigned long lastPresCheck = 0, awayStart = 0;
int leaveCount = 0;
#define PRESENCE_CM 100
 
// Eyes mood
enum EyeMood { EYE_NORMAL, EYE_HAPPY, EYE_DEAD, EYE_HEART, EYE_SLEEPY };
EyeMood eyeMood = EYE_NORMAL;
unsigned long celebrateUntil = 0;
 
// Blink + pupil drift
unsigned long nextBlink = 0, blinkEnd = 0, nextDrift = 0;
bool blinking = false;
float pupilX = 0, pupilY = 0, targetPX = 0, targetPY = 0;
 
// Sessions
struct Session { int num; int durMin; String emotion; };
#define MAX_SESS 20
Session sessions[MAX_SESS];
int sessCount = 0, needsEmo = -1;
 
WebServer server(80);
 
float readDist() {
digitalWrite(TRIG_PIN, LOW); delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH); delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
long d = pulseIn(ECHO_PIN, HIGH, 30000);
return d == 0 ? 999.0 : d * 0.0343 / 2.0;
}
 
void updateEyeMood() {
unsigned long now = millis();
if (now < celebrateUntil) { eyeMood = EYE_HEART; return; } // finished a session
if (!present) { eyeMood = EYE_DEAD; return; } // you walked away
switch (state) {
case IDLE: eyeMood = EYE_HAPPY; break;
case FOCUS: eyeMood = EYE_NORMAL; break;
case BREAK_TIME: eyeMood = EYE_SLEEPY; break;
case PAUSED: eyeMood = EYE_NORMAL; break;
}
}
 
// ── OLED: draw the cute face ──────────────────────────────────
void drawOLED() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
unsigned long now = millis();
updateEyeMood();
 
// Blink logic
if (!blinking && now > nextBlink && eyeMood == EYE_NORMAL) { blinking = true; blinkEnd = now + 150; }
if (blinking && now > blinkEnd) { blinking = false; nextBlink = now + random(2000, 5000); }
 
// Pupil drift (eyes wander a little)
if (now > nextDrift) { targetPX = random(-3, 4); targetPY = random(-2, 3); nextDrift = now + random(800, 2500); }
pupilX += (targetPX - pupilX) * 0.2;
pupilY += (targetPY - pupilY) * 0.2;
 
int lx = 44, rx = 84, ey = 24; // eye centers
int eyeW = 20, eyeH = 22;
 
if (blinking) {
oled.fillRoundRect(lx-eyeW/2, ey, eyeW, 3, 1, SSD1306_WHITE);
oled.fillRoundRect(rx-eyeW/2, ey, eyeW, 3, 1, SSD1306_WHITE);
} else {
switch (eyeMood) {
case EYE_NORMAL:
case EYE_HAPPY: {
oled.fillRoundRect(lx-eyeW/2, ey-eyeH/2, eyeW, eyeH, 5, SSD1306_WHITE);
oled.fillRoundRect(rx-eyeW/2, ey-eyeH/2, eyeW, eyeH, 5, SSD1306_WHITE);
int pw = 8, ph = 10;
int lpx = lx + (int)pupilX - pw/2, lpy = ey + (int)pupilY - ph/2;
int rpx = rx + (int)pupilX - pw/2, rpy = ey + (int)pupilY - ph/2;
oled.fillRoundRect(lpx, lpy, pw, ph, 3, SSD1306_BLACK);
oled.fillRoundRect(rpx, rpy, pw, ph, 3, SSD1306_BLACK);
oled.fillCircle(lpx + 2, lpy + 2, 2, SSD1306_WHITE); // shine
oled.fillCircle(rpx + 2, rpy + 2, 2, SSD1306_WHITE);
if (eyeMood == EYE_HAPPY) { // squinty bottom
oled.fillRect(lx-eyeW/2, ey + eyeH/2 - 6, eyeW, 6, SSD1306_BLACK);
oled.fillRect(rx-eyeW/2, ey + eyeH/2 - 6, eyeW, 6, SSD1306_BLACK);
}
break;
}
case EYE_DEAD: { // X X
int s = 8;
oled.drawLine(lx-s, ey-s, lx+s, ey+s, SSD1306_WHITE);
oled.drawLine(lx+s, ey-s, lx-s, ey+s, SSD1306_WHITE);
oled.drawLine(rx-s, ey-s, rx+s, ey+s, SSD1306_WHITE);
oled.drawLine(rx+s, ey-s, rx-s, ey+s, SSD1306_WHITE);
break;
}
case EYE_HEART: { // <3 <3
for (int cx : {lx, rx}) {
oled.fillCircle(cx - 5, ey - 3, 6, SSD1306_WHITE);
oled.fillCircle(cx + 5, ey - 3, 6, SSD1306_WHITE);
oled.fillTriangle(cx - 11, ey, cx + 11, ey, cx, ey + 12, SSD1306_WHITE);
}
break;
}
case EYE_SLEEPY: { // closed + zzz
oled.fillRoundRect(lx-eyeW/2, ey-1, eyeW, 3, 1, SSD1306_WHITE);
oled.fillRoundRect(rx-eyeW/2, ey-1, eyeW, 3, 1, SSD1306_WHITE);
oled.setCursor(108, 10); oled.print("z");
oled.setCursor(115, 4); oled.print("z");
break;
}
}
}
 
// Timer readout at the bottom
int mins = remaining / 60, secs = remaining % 60;
oled.setCursor(0, 56);
switch (state) {
case IDLE: oled.print("READY"); break;
case FOCUS: oled.printf("%02d:%02d", mins, secs); break;
case BREAK_TIME: oled.printf("BRK %02d:%02d", mins, secs); break;
case PAUSED: oled.printf("|| %02d:%02d", mins, secs); break;
}
oled.setCursor(100, 56); oled.printf("#%d", completed);
oled.display();
}
 
void logSession() {
int i = sessCount % MAX_SESS;
sessions[i].num = completed;
sessions[i].durMin = focusDur / 60;
sessions[i].emotion = "";
needsEmo = i;
sessCount++;
}
 
// ── Web dashboard (HTML / CSS / JS) served from PROGMEM — omitted here for brevity ──
const char PAGE[] PROGMEM = R"( ...full Focus Bot web UI... )";
 
const char* moodStr() {
switch (eyeMood) {
case EYE_NORMAL: case EYE_HAPPY: return "normal";
case EYE_DEAD: return "dead";
case EYE_HEART: return "heart";
case EYE_SLEEPY: return "sleepy";
default: return "normal";
}
}
 
void handleRoot() { server.send_P(200, "text/html", PAGE); }
 
void handleStatus() {
updateEyeMood();
JsonDocument doc;
doc["state"] = (int)state; doc["remaining"] = remaining;
doc["present"] = present; doc["mood"] = moodStr();
doc["needsEmotion"] = (needsEmo >= 0);
JsonArray arr = doc["sessions"].to<JsonArray>();
int st = sessCount > MAX_SESS ? sessCount - MAX_SESS : 0;
for (int i = st; i < sessCount; i++) {
int x = i % MAX_SESS; JsonObject s = arr.add<JsonObject>();
s["n"] = sessions[x].num; s["dur"] = sessions[x].durMin; s["emo"] = sessions[x].emotion;
}
String out; serializeJson(doc, out);
server.send(200, "application/json", out);
}
 
void handleStart() { if (state == IDLE) { state = FOCUS; remaining = focusDur; lastTick = millis(); leaveCount = 0; } server.send(200, "application/json", "{\"ok\":1}"); }
void handlePause() { if (state == FOCUS || state == BREAK_TIME) { stateBeforePause = state; state = PAUSED; } server.send(200, "application/json", "{\"ok\":1}"); }
void handleResume() { if (state == PAUSED) { state = stateBeforePause; lastTick = millis(); } server.send(200, "application/json", "{\"ok\":1}"); }
void handleReset() { state = IDLE; remaining = focusDur; server.send(200, "application/json", "{\"ok\":1}"); }
 
void handleSettings() {
if (server.hasArg("plain")) {
JsonDocument doc; deserializeJson(doc, server.arg("plain"));
if (doc["focus"]) focusDur = doc["focus"].as<int>() * 60;
if (doc["brk"]) breakDur = doc["brk"].as<int>() * 60;
if (state == IDLE) remaining = focusDur;
}
server.send(200, "application/json", "{\"ok\":1}");
}
 
void setup() {
Serial.begin(115200);
pinMode(TRIG_PIN, OUTPUT); pinMode(ECHO_PIN, INPUT);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED fail");
oled.clearDisplay(); oled.display();
 
if (USE_AP) { WiFi.softAP(AP_SSID, AP_PASS); }
else {
WiFi.begin(WIFI_SSID, WIFI_PASS); int t = 0;
while (WiFi.status() != WL_CONNECTED && t < 40) { delay(500); t++; }
if (WiFi.status() != WL_CONNECTED) WiFi.softAP(AP_SSID, AP_PASS); // fallback
}
 
server.on("/", HTTP_GET, handleRoot);
server.on("/api/status", HTTP_GET, handleStatus);
server.on("/api/start", HTTP_POST, handleStart);
server.on("/api/pause", HTTP_POST, handlePause);
server.on("/api/resume", HTTP_POST, handleResume);
server.on("/api/reset", HTTP_POST, handleReset);
server.on("/api/settings", HTTP_POST, handleSettings);
server.begin();
 
remaining = focusDur;
nextBlink = millis() + random(2000, 5000);
nextDrift = millis() + 500;
drawOLED();
}
 
void loop() {
server.handleClient();
unsigned long now = millis();
 
// Presence: walking away mid-focus pauses the timer
if (now - lastPresCheck > 800) {
lastPresCheck = now;
float d = readDist(); prevPresent = present; present = (d < PRESENCE_CM);
if (!present && prevPresent && state == FOCUS) { leaveCount++; stateBeforePause = FOCUS; state = PAUSED; }
if (present && !prevPresent && state == PAUSED && stateBeforePause == FOCUS) { state = FOCUS; lastTick = now; }
}
 
// Tick the countdown
if ((state == FOCUS || state == BREAK_TIME) && now - lastTick >= 1000) {
lastTick = now;
if (remaining > 0) remaining--;
else {
if (state == FOCUS) {
completed++; logSession(); celebrateUntil = now + 4000;
state = BREAK_TIME; remaining = (completed % sessionsForLong == 0) ? longBreakDur : breakDur;
} else { state = IDLE; remaining = focusDur; }
}
}
 
// Redraw fast for smooth eye animation
static unsigned long lastDr = 0;
if (now - lastDr > 80) { lastDr = now; drawOLED(); }
}
 
The moods

How Buddy reads your focus.

Buddy borrows your laptop camera to tell whether you're really focused, and reacts with changing emotions and different sounds.

auto
Idle
Idle

Sitting on your laptop, waiting for you to start.

Focus loop

Four sessions, and a timer that won't let you cheat.

Four 25 minute focus blocks with breaks between them. The twist: reach for your phone and the countdown freezes. Buddy turns angry and the clock won't move again until you put it down. Watch it run.

session 1/4
25:00
Focusing

The working prototype

Under the hood

How it knows you've drifted.

Three signals tell Buddy whether you're really focused, without you ever touching it.

Ultrasonic sensor

A distance sensor tells Buddy when you're actually at your desk, and when you've wandered off.

Camera attention

Buddy borrows your laptop camera to read your focus. Drift to your phone and it notices immediately.

Siri voice

Start a session hands free. No app to open, no screen to unlock. Just say the word.

What's next

Off the breadboard, onto the desk.

Buddy works, but it still lives on a breadboard. We ran out of time to 3D print an enclosure, so that's the next move: a proper desk object you'd actually want sitting out. Something like this.

DeskBuddy as a finished desk object