/*
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(); }
}