#include #include #include #include #include #include #include #include #include #include #include #include // ================= PERSISTENT CONFIG ================== Preferences prefs; struct DeviceConfig { String ssid; String password; String apiKey; String placeName; float latitude; float longitude; int faceMode; }; DeviceConfig cfg; String ipString = ""; // Wi-Fi provisioning bool isProvisioned = false; bool inProvisionAP = false; const char* PROV_AP_SSID_PREFIX = "DeskBot-"; const char* PROV_AP_PASSWORD = "deskbot123"; IPAddress apIP(192, 168, 4, 1); // Defaults (first boot / after reset) – now empty Wi‑Fi so reset is clean const char* DEFAULT_SSID = ""; const char* DEFAULT_PASSWORD = ""; const char* DEFAULT_API_KEY = ""; const char* DEFAULT_PLACE = ""; #define DEFAULT_LATITUDE 0.00 #define DEFAULT_LONGITUDE 0.00 const long GMT_OFFSET_SEC = 19800; const int DAYLIGHT_OFFSET_S = 0; // ============== PINS & SCREEN =================== #define TOUCH_PIN 27 #define OLED_MOSI 23 #define OLED_CLK 18 #define OLED_DC 16 #define OLED_CS 5 #define OLED_RESET 17 #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &SPI, OLED_DC, OLED_RESET, OLED_CS); // ============== SYNC WEB SERVER ================= WebServer server(80); // ========== INPUT STATE MACHINE ================= const int LONG_PRESS_TIME = 600; const int DOUBLE_TAP_DELAY = 350; const unsigned long MENU_IDLE_TIMEOUT = 10000; const unsigned long SLEEP_TIMEOUT = 60000; unsigned long touchStartTime = 0; bool isTouching = false; bool isLongPressing = false; unsigned long lastTapTime = 0; int tapCount = 0; // ========= BOT STATE ============================ struct BotState { int faceMode; bool powerOn; bool isSleeping; bool isBeingPetted; bool isFurious; bool isComforted; } bot; unsigned long lastInteractionTime = 0; bool isPuppySquint = false; unsigned long squintEndTime = 0; bool isRejected = false; unsigned long rejectEndTime = 0; // ========== ANIMATION / WEATHER VARS ============ float outdoorTemp = NAN; float outdoorHum = NAN; float pm25 = NAN; bool weatherReady = false; bool timeReady = false; unsigned long lastWeatherUpdate = 0; unsigned long lastWeatherAttempt = 0; unsigned long lastAQIUpdate = 0; unsigned long lastAQIAttempt = 0; // Alive Logic int lookDirection = 0; unsigned long nextLookTime = 0; bool isYawning = false; unsigned long yawnEndTime = 0; bool hasMidYawned = false; bool hasFinalYawned = false; bool isDriftingOff = false; unsigned long randomMidYawnTime = 0; // Physics float tearY = 0; float spiralAngle = 0.0; unsigned long lastBlinkTime = 0; bool isBlinking = false; float heartScale = 1.0; // Face Geometry const int BASE_EYE_W = 30; const int EYE_H = 44; const int EYE_Y = 5; const int EYE_X_L = 16; const int EYE_X_R = 82; const int EYE_RADIUS = 8; const int MOUTH_Y = 42; // Transitions float currentEyeW_L = BASE_EYE_W; float currentEyeW_R = BASE_EYE_W; float currentMouthX = 0; float currentYawnFactor = 0.0; float currentEyeOpenFactor = 1.0; float targetEyeW_L = BASE_EYE_W; float targetEyeW_R = BASE_EYE_W; float targetMouthX = 0; float targetYawnFactor = 0.0; float targetEyeOpenFactor = 1.0; const float PAN_SPEED = 12.0; const float YAWN_SPEED = 0.08; const float SLEEP_SPEED = 0.05; // DEV MODE FLAG bool devMode = false; // deferred WiFi reconnect flag (after config) bool needReconnect = false; unsigned long reconnectAt = 0; // IP display state bool showingIPForSetup = false; String displayIP = ""; unsigned long ipDisplayStartTime = 0; const unsigned long IP_DISPLAY_TIMEOUT = 120000; // 2 minutes // ============== PROTOTYPES ====================== void handleInput(); void triggerLongPressAction(); void releaseLongPress(); void drawEyes(); void drawMouth(); void showClock(); void showTempScreen(); void showHumScreen(); void showPM25Screen(); void fetchWeatherOWM(); void fetchAQI_OWM(); void syncTimeIST(); void updateAliveAnimations(); void updateHeartbeat(); void triggerYawn(int duration); void centerText(const char* txt, int y, int size); void showMessage(const char* msg); const char* pm25Category(float pm); void devModeLoop(); void drawHeart(int x, int y, float scale); void drawSingleTear(int x, int y); void drawTears(); void drawSpiral(int centerX, int centerY, int direction, float rotationOffset); void drawAngryFire(int centerX, int bottomY); // mark remote (app) interactions so idle/sleep resets void markRemoteInteraction() { lastInteractionTime = millis(); bot.isSleeping = false; isDriftingOff = false; // Hide IP display when app connects successfully if (showingIPForSetup) { showingIPForSetup = false; displayIP = ""; Serial.println("App connected - hiding IP display"); } } // ============== CONFIG LOAD/SAVE ====================== void loadConfig() { prefs.begin("deskbot", true); cfg.ssid = prefs.getString("ssid", DEFAULT_SSID); cfg.password = prefs.getString("password", DEFAULT_PASSWORD); cfg.apiKey = prefs.getString("apikey", DEFAULT_API_KEY); cfg.placeName = prefs.getString("place", DEFAULT_PLACE); cfg.latitude = prefs.getFloat("lat", DEFAULT_LATITUDE); cfg.longitude = prefs.getFloat("lon", DEFAULT_LONGITUDE); cfg.faceMode = prefs.getInt("faceMode", 0); isProvisioned = prefs.getBool("prov", false); prefs.end(); } void saveConfig() { prefs.begin("deskbot", false); prefs.putString("ssid", cfg.ssid); prefs.putString("password", cfg.password); prefs.putString("apikey", cfg.apiKey); prefs.putString("place", cfg.placeName); prefs.putFloat("lat", cfg.latitude); prefs.putFloat("lon", cfg.longitude); prefs.putInt("faceMode", cfg.faceMode); prefs.putBool("prov", isProvisioned); prefs.end(); } // ============== FACTORY RESET ====================== void factoryReset() { prefs.begin("deskbot", false); prefs.clear(); prefs.end(); // Clear runtime config as well cfg.ssid = ""; cfg.password = ""; cfg.apiKey = ""; cfg.placeName = ""; cfg.latitude = 0.00; cfg.longitude = 0.00; cfg.faceMode = 0; isProvisioned = false; showMessage("Factory reset"); delay(1000); ESP.restart(); } // ============== HTTP HANDLERS ======================= void handleGetConfig() { DynamicJsonDocument doc(512); doc["ssid"] = cfg.ssid; doc["password"] = cfg.password; doc["apiKey"] = cfg.apiKey; doc["placeName"] = cfg.placeName; doc["latitude"] = cfg.latitude; doc["longitude"] = cfg.longitude; doc["faceMode"] = cfg.faceMode; doc["powerOn"] = bot.powerOn; String out; serializeJson(doc, out); server.send(200, "application/json", out); markRemoteInteraction(); } // live state for app polling void handleGetState() { // Add CORS headers server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); server.sendHeader("Access-Control-Allow-Headers", "Content-Type"); DynamicJsonDocument doc(512); doc["faceMode"] = bot.faceMode; doc["powerOn"] = bot.powerOn; doc["isSleeping"] = bot.isSleeping; doc["isBeingPetted"] = bot.isBeingPetted; doc["isFurious"] = bot.isFurious; doc["isComforted"] = bot.isComforted; doc["uptime"] = millis(); doc["timeReady"] = timeReady; doc["weatherReady"] = weatherReady; doc["wifiConnected"] = (WiFi.status() == WL_CONNECTED); doc["ipAddress"] = WiFi.localIP().toString(); doc["wifiSSID"] = WiFi.SSID(); doc["chipId"] = String((uint32_t)ESP.getEfuseMac(), HEX); String out; serializeJson(doc, out); server.send(200, "application/json", out); markRemoteInteraction(); } void handlePostConfig() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "Body missing"); return; } String body = server.arg("plain"); StaticJsonDocument<512> doc; DeserializationError err = deserializeJson(doc, body); if (err) { server.send(400, "text/plain", "JSON error"); return; } bool ssidChanged = false; bool passChanged = false; if (doc.containsKey("ssid")) { String newSsid = (const char*)doc["ssid"]; if (newSsid.length() > 0 && newSsid != cfg.ssid) { cfg.ssid = newSsid; ssidChanged = true; } } if (doc.containsKey("password")) { String newPass = (const char*)doc["password"]; if (newPass.length() > 0 && newPass != cfg.password) { cfg.password = newPass; passChanged = true; } } if (doc.containsKey("apiKey")) { String newKey = (const char*)doc["apiKey"]; if (newKey.length() > 0) { cfg.apiKey = newKey; } } if (doc.containsKey("placeName")) { String newPlace = (const char*)doc["placeName"]; if (newPlace.length() > 0) { cfg.placeName = newPlace; } } if (doc.containsKey("latitude")) { cfg.latitude = doc["latitude"].as(); } if (doc.containsKey("longitude")) { cfg.longitude = doc["longitude"].as(); } if (doc.containsKey("faceMode")) { int fm = doc["faceMode"].as(); if (fm < 0) fm = 0; if (fm > 8) fm = 8; cfg.faceMode = fm; bot.faceMode = fm; } saveConfig(); server.send(200, "text/plain", "OK"); markRemoteInteraction(); if (ssidChanged || passChanged) { needReconnect = true; reconnectAt = millis() + 1000; } } void handlePostFace() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "Body missing"); return; } String body = server.arg("plain"); StaticJsonDocument<128> doc; if (deserializeJson(doc, body)) { server.send(400, "text/plain", "JSON error"); return; } int newFace = doc["faceMode"] | 0; if (newFace < 0) newFace = 0; if (newFace > 8) newFace = 8; cfg.faceMode = newFace; saveConfig(); // Auto-turn ON when setting face from app bot.powerOn = true; bot.isSleeping = false; bot.faceMode = newFace; lastInteractionTime = millis(); isDriftingOff = false; hasMidYawned = false; hasFinalYawned = false; currentEyeOpenFactor = targetEyeOpenFactor = 1.0; markRemoteInteraction(); server.send(200, "text/plain", "Face changed"); } // /power: OFF -> sleep face; ON -> idle blinking (mode 0) void handlePostPower() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "Body missing"); return; } String body = server.arg("plain"); StaticJsonDocument<64> doc; if (deserializeJson(doc, body)) { server.send(400, "text/plain", "JSON error"); return; } bool newState = doc["powerOn"] | true; bot.powerOn = newState; markRemoteInteraction(); if (!bot.powerOn) { // OFF: go to sleep face (mode 0 + sleeping) bot.isSleeping = true; bot.faceMode = 0; isDriftingOff = false; hasMidYawned = false; hasFinalYawned = false; } else { // ON: wake into idle blinking (mode 0) bot.isSleeping = false; bot.faceMode = 0; lastInteractionTime = millis(); isDriftingOff = false; hasMidYawned = false; hasFinalYawned = false; currentEyeOpenFactor = targetEyeOpenFactor = 1.0; } server.send(200, "text/plain", bot.powerOn ? "Power ON" : "Power OFF"); } void handlePostAction() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "Body missing"); return; } String body = server.arg("plain"); StaticJsonDocument<128> doc; if (deserializeJson(doc, body)) { server.send(400, "text/plain", "JSON error"); return; } String type = doc["type"] | ""; bool active = doc["active"] | false; if (type == "pet") { bot.isBeingPetted = active; } else if (type == "fury") { bot.isFurious = active; } else if (type == "comfort") { bot.isComforted = active; } else { server.send(400, "text/plain", "Invalid action type"); return; } markRemoteInteraction(); server.send(200, "text/plain", "Action set"); } // /confirm-ip: called by app when IP is successfully entered void handleConfirmIP() { // Add CORS headers server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); server.sendHeader("Access-Control-Allow-Headers", "Content-Type"); showingIPForSetup = false; displayIP = ""; DynamicJsonDocument response(128); response["status"] = "confirmed"; response["message"] = "IP confirmed, normal operation resumed"; String out; serializeJson(response, out); server.send(200, "application/json", out); markRemoteInteraction(); Serial.println("IP confirmed by app - resuming normal operation"); } // ---------- PROVISIONING HANDLERS (SoftAP mode) ---------- // GET /prov/info -> basic device info so app can confirm it's a DeskBot void handleProvInfo() { DynamicJsonDocument doc(256); doc["name"] = "DeskBot"; doc["fwVersion"] = "1.0.0"; doc["chipId"] = String((uint32_t)ESP.getEfuseMac(), HEX); String out; serializeJson(doc, out); server.send(200, "application/json", out); } // POST /prov/config -> receive Wi-Fi + API config during provisioning void handleProvConfig() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "Body missing"); return; } String body = server.arg("plain"); StaticJsonDocument<512> doc; DeserializationError err = deserializeJson(doc, body); if (err) { server.send(400, "text/plain", "JSON error"); return; } String newSsid = doc["ssid"] | ""; String newPass = doc["password"] | ""; String newKey = doc["apiKey"] | ""; String newPlace = doc["placeName"] | ""; float newLat = doc["latitude"] | DEFAULT_LATITUDE; float newLon = doc["longitude"] | DEFAULT_LONGITUDE; if (newSsid.length() == 0 || newPass.length() == 0) { server.send(400, "text/plain", "SSID and password required"); return; } cfg.ssid = newSsid; cfg.password = newPass; if (newKey.length() > 0) cfg.apiKey = newKey; if (newPlace.length() > 0) cfg.placeName = newPlace; cfg.latitude = newLat; cfg.longitude = newLon; isProvisioned = true; saveConfig(); // Send response first DynamicJsonDocument response(256); response["status"] = "success"; response["message"] = "Provisioned OK - Connecting to WiFi"; response["willConnectTo"] = newSsid; String out; serializeJson(response, out); server.send(200, "application/json", out); // IMMEDIATELY start WiFi connection process delay(1000); // Give response time to send Serial.println("Starting WiFi connection..."); showMessage("Connecting WiFi..."); // Switch to STA mode and connect WiFi.mode(WIFI_STA); delay(500); WiFi.begin(cfg.ssid.c_str(), cfg.password.c_str()); // Wait for connection with timeout unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); Serial.print("."); } if (WiFi.status() == WL_CONNECTED) { Serial.println("\nWiFi connected successfully!"); Serial.print("IP: "); Serial.println(WiFi.localIP()); ipString = WiFi.localIP().toString(); // Show IP for manual entry in app showingIPForSetup = true; displayIP = ipString; ipDisplayStartTime = millis(); // Update display to show IP prominently display.clearDisplay(); centerText("Network Details", 0, 1); display.setTextSize(1); display.setCursor(0, 12); display.print("Network: "); display.println(cfg.ssid); // Show IP in readable size display.setCursor(0, 24); display.print("IP: "); display.println(ipString); display.setCursor(0, 36); display.print("Enter this IP"); display.setCursor(0, 48); display.print("in the app"); display.display(); delay(2000); // Setup services with proper delays showMessage("Syncing time..."); syncTimeIST(); delay(1000); showMessage("Getting weather..."); fetchWeatherOWM(); delay(1000); showMessage("Getting AQI..."); fetchAQI_OWM(); delay(1000); showMessage("Ready!"); delay(1000); } else { Serial.println("\nWiFi connection failed!"); showMessage("WiFi failed"); } } // GET /prov/status -> get current connection status void handleProvStatus() { DynamicJsonDocument doc(256); doc["isProvisioned"] = isProvisioned; // Only show AP info if we're actually in AP mode if (WiFi.getMode() == WIFI_AP || WiFi.getMode() == WIFI_AP_STA) { doc["apActive"] = true; doc["apIP"] = WiFi.softAPIP().toString(); } else { doc["apActive"] = false; doc["apIP"] = ""; } if (WiFi.status() == WL_CONNECTED) { doc["staConnected"] = true; doc["staIP"] = WiFi.localIP().toString(); doc["ssid"] = WiFi.SSID(); } else { doc["staConnected"] = false; doc["staIP"] = ""; doc["ssid"] = cfg.ssid; } String out; serializeJson(doc, out); server.send(200, "application/json", out); } // Add a simple root handler for browser access void handleRoot() { String html = "DeskBot Setup"; html += "

DeskBot Configuration

"; html += "

Device is ready for setup via mobile app.

"; html += "

Available endpoints:

"; html += ""; html += ""; server.send(200, "text/html", html); } void setupHttpRoutes() { // Add root handler for browser access server.on("/", HTTP_GET, handleRoot); server.on("/config", HTTP_GET, handleGetConfig); server.on("/config", HTTP_POST, handlePostConfig); server.on("/face", HTTP_POST, handlePostFace); server.on("/power", HTTP_POST, handlePostPower); server.on("/state", HTTP_GET, handleGetState); server.on("/action", HTTP_POST, handlePostAction); server.on("/confirm-ip", HTTP_POST, handleConfirmIP); // Provisioning endpoints (used when device is in SoftAP mode) server.on("/prov/info", HTTP_GET, handleProvInfo); server.on("/prov/config", HTTP_POST, handleProvConfig); server.on("/prov/status", HTTP_GET, handleProvStatus); // Handle CORS for browser access server.onNotFound([]() { if (server.method() == HTTP_OPTIONS) { server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); server.sendHeader("Access-Control-Allow-Headers", "Content-Type"); server.send(200); } else { server.send(404, "text/plain", "Not found"); } }); } // ============== SETUP ========================== void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("Booting DeskBot..."); pinMode(TOUCH_PIN, INPUT); SPI.begin(OLED_CLK, -1, OLED_MOSI, OLED_CS); if (!display.begin(SSD1306_SWITCHCAPVCC)) { Serial.println(F("SSD1306 allocation failed")); for (;;); } display.setTextColor(SSD1306_WHITE); display.clearDisplay(); centerText("JsConnect", 16, 2); centerText("DeskBot", 38, 1); display.display(); delay(1500); // --------- BOOT-TIME FACTORY RESET CHECK ---------- display.clearDisplay(); centerText("Hold touch", 16, 1); centerText("to reset...", 32, 1); display.display(); unsigned long resetStart = millis(); bool resetTriggered = false; // 4s window to start holding while (millis() - resetStart < 4000) { if (digitalRead(TOUCH_PIN)) { resetTriggered = true; break; } delay(20); } if (resetTriggered) { display.clearDisplay(); centerText("Keep holding", 16, 1); centerText("for reset", 32, 1); display.display(); unsigned long holdStart = millis(); while (digitalRead(TOUCH_PIN)) { if (millis() - holdStart > 3000) { // ~3s hold factoryReset(); } delay(20); } } // -------------------------------------------------- loadConfig(); // init bot state bot.faceMode = 0; bot.powerOn = true; bot.isSleeping = false; bot.isBeingPetted = false; bot.isFurious = false; bot.isComforted = false; // ---------- FIXED AP SETUP ---------- String apSsid = String(PROV_AP_SSID_PREFIX) + String((uint32_t)ESP.getEfuseMac(), HEX); // CRITICAL: Disconnect and reset WiFi completely WiFi.disconnect(true, true); WiFi.mode(WIFI_OFF); delay(500); // Start in AP mode first WiFi.mode(WIFI_AP); delay(200); // Configure AP with proper settings WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); bool apStarted = WiFi.softAP(apSsid.c_str(), PROV_AP_PASSWORD, 1, 0, 4); if (!apStarted) { Serial.println("AP Start Failed!"); // Try again with different settings delay(1000); WiFi.softAP(apSsid.c_str(), PROV_AP_PASSWORD); } Serial.println("DeskBot AP started"); Serial.print("SSID: "); Serial.println(apSsid); Serial.print("Password: "); Serial.println(PROV_AP_PASSWORD); Serial.print("AP IP: "); Serial.println(WiFi.softAPIP()); // OLED AP info display.clearDisplay(); centerText("DeskBot WiFi", 0, 1); display.setTextSize(1); display.setCursor(0, 20); display.print("AP: "); display.println(apSsid); display.setCursor(0, 32); display.print("Pass: "); display.println(PROV_AP_PASSWORD); display.setCursor(0, 48); display.print("Open: 192.168.4.1"); display.display(); // Start HTTP server BEFORE trying STA mode setupHttpRoutes(); ElegantOTA.begin(&server); server.begin(); Serial.println("HTTP server started on AP"); // STA only if provisioned and have credentials ipString = ""; if (isProvisioned && cfg.ssid.length() > 0 && cfg.password.length() > 0) { showMessage("Connecting WiFi..."); // CRITICAL: Switch to STA-only mode (turn OFF AP) WiFi.mode(WIFI_STA); delay(500); // Connect to home WiFi WiFi.begin(cfg.ssid.c_str(), cfg.password.c_str()); Serial.print("Connecting to WiFi"); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) { delay(500); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { Serial.println("WiFi connected - AP mode disabled"); Serial.print("IP address: "); Serial.println(WiFi.localIP()); ipString = WiFi.localIP().toString(); // Show IP for manual entry in app showingIPForSetup = true; displayIP = ipString; ipDisplayStartTime = millis(); // Update OLED to show IP prominently for manual entry display.clearDisplay(); centerText("Network Details", 0, 1); display.setTextSize(1); display.setCursor(0, 12); display.print("Network: "); display.println(cfg.ssid); // Show IP in readable size display.setCursor(0, 24); display.print("IP: "); display.println(ipString); display.setCursor(0, 36); display.print("Enter this IP"); display.setCursor(0, 48); display.print("in the app"); display.display(); delay(3000); // ---------- mDNS AUTO-DISCOVERY ---------- String hostName = "deskbot-" + String((uint32_t)ESP.getEfuseMac(), HEX); hostName.toLowerCase(); if (!MDNS.begin(hostName.c_str())) { Serial.println("Error setting up mDNS responder!"); } else { Serial.print("mDNS: http://"); Serial.print(hostName); Serial.println(".local"); MDNS.addService("http", "tcp", 80); } showMessage("Syncing time..."); syncTimeIST(); showMessage("Fetching weather..."); fetchWeatherOWM(); showMessage("Fetching AQI..."); fetchAQI_OWM(); } else { Serial.println("WiFi connect timeout - restarting in AP mode"); showMessage("WiFi failed - restarting"); delay(2000); ESP.restart(); // Restart to go back to AP mode } } else { Serial.println("Not provisioned - staying in AP mode for setup"); } randomSeed(analogRead(0)); lastInteractionTime = millis(); randomMidYawnTime = random(20000, 40000); } // ============== DEV MODE ======================= void devModeLoop() { bool wifiOK = false; bool apiOK = false; // Initial OLED test display.clearDisplay(); centerText("DEV MODE", 0, 1); centerText("Checking OLED", 20, 1); display.drawRect(10, 35, 108, 20, WHITE); display.display(); delay(1000); // Quick WiFi/API check display.clearDisplay(); centerText("DEV MODE", 0, 1); centerText("WiFi...", 24, 1); display.display(); wifiOK = false; apiOK = false; // Only try WiFi if we have non-empty credentials if (cfg.ssid.length() > 0 && cfg.password.length() > 0) { WiFi.begin(cfg.ssid.c_str(), cfg.password.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 5000) { delay(500); } wifiOK = (WiFi.status() == WL_CONNECTED); if (wifiOK) { fetchWeatherOWM(); fetchAQI_OWM(); apiOK = weatherReady && !isnan(pm25); ipString = WiFi.localIP().toString(); } } // DEV MODE screen loop; exit on 10 taps tapCount = 0; devMode = true; bool lastTouch = false; unsigned long lastTapTimeDev = 0; while (devMode) { display.clearDisplay(); centerText("DEV MODE", 0, 1); display.setTextSize(1); display.setCursor(0, 16); display.print("IP: "); display.println(ipString.length() ? ipString : "No WiFi"); display.setCursor(0, 26); display.print("WiFi: "); display.print(wifiOK ? "OK" : "FAIL"); display.setCursor(0, 36); display.print("API : "); display.print(apiOK ? "OK" : "FAIL"); display.setCursor(0, 50); display.print("10 taps to exit"); display.display(); // simple tap detection inside DEV MODE bool touch = digitalRead(TOUCH_PIN); if (touch && !lastTouch) { lastTouch = true; } if (!touch && lastTouch) { lastTouch = false; // one tap detected if (millis() - lastTapTimeDev < 500) { tapCount++; } else { tapCount = 1; } lastTapTimeDev = millis(); if (tapCount >= 10) { devMode = false; // exit DEV MODE } } delay(20); } // leaving DEV MODE – reset tap counter for normal input tapCount = 0; } // ============== LOOP =========================== void loop() { server.handleClient(); unsigned long currentMillis = millis(); if (needReconnect && millis() > reconnectAt) { needReconnect = false; WiFi.disconnect(true); WiFi.begin(cfg.ssid.c_str(), cfg.password.c_str()); } handleInput(); // menu idle: after 10s in any non-idle mode, snap back to idle (faceMode 0) if (!bot.isSleeping && bot.faceMode != 0) { unsigned long elapsedMenu = currentMillis - lastInteractionTime; if (elapsedMenu > MENU_IDLE_TIMEOUT) { bot.faceMode = 0; isPuppySquint = false; bot.isBeingPetted = false; isRejected = false; bot.isFurious = false; bot.isComforted = false; isDriftingOff = false; hasMidYawned = false; hasFinalYawned = false; currentEyeOpenFactor = targetEyeOpenFactor = 1.0; } } // sleep timer: only when in idle 0 and not being petted if (!bot.isSleeping && bot.faceMode == 0 && !bot.isBeingPetted) { unsigned long elapsed = currentMillis - lastInteractionTime; if (!hasMidYawned && elapsed > randomMidYawnTime) { triggerYawn(2500); hasMidYawned = true; } if (!hasFinalYawned && elapsed > (SLEEP_TIMEOUT - 6000)) { triggerYawn(3500); hasFinalYawned = true; } if (!isDriftingOff && elapsed > (SLEEP_TIMEOUT - 2000)) { isDriftingOff = true; targetEyeOpenFactor = 0.0; targetYawnFactor = 0.0; targetMouthX = 0; } if (elapsed > SLEEP_TIMEOUT) { bot.isSleeping = true; bot.faceMode = 0; // ensure we always sleep from idle } } // STA reconnect and weather logic only when provisioned if (isProvisioned && WiFi.status() != WL_CONNECTED && (currentMillis - lastWeatherAttempt > 15000)) { Serial.println("WiFi disconnected, attempting reconnect..."); WiFi.reconnect(); lastWeatherAttempt = currentMillis; } // Only fetch data if WiFi is connected and we have API key if (isProvisioned && WiFi.status() == WL_CONNECTED && cfg.apiKey.length() > 0) { bool needWeatherUpdate = (currentMillis - lastWeatherUpdate > 600000); bool missingWeatherData = (!weatherReady && (currentMillis - lastWeatherAttempt > 10000)); if (needWeatherUpdate || missingWeatherData) { fetchWeatherOWM(); lastWeatherAttempt = currentMillis; } bool needAQIUpdate = (currentMillis - lastAQIUpdate > 900000); bool missingAQIData = (isnan(pm25) && (currentMillis - lastAQIAttempt > 15000)); if (needAQIUpdate || missingAQIData) { fetchAQI_OWM(); lastAQIAttempt = currentMillis; } // Sync time if not ready if (!timeReady && (currentMillis - lastWeatherAttempt > 30000)) { syncTimeIST(); lastWeatherAttempt = currentMillis; } } display.clearDisplay(); // Auto-hide IP display after timeout or if app has connected if (showingIPForSetup && (millis() - ipDisplayStartTime > IP_DISPLAY_TIMEOUT)) { showingIPForSetup = false; displayIP = ""; } if (showingIPForSetup) { // Show IP prominently for manual entry centerText("Network Details", 0, 1); display.setTextSize(1); display.setCursor(0, 12); display.print("Network: "); display.println(cfg.ssid); // Show IP in readable size display.setCursor(0, 24); display.print("IP: "); display.println(displayIP); display.setCursor(0, 36); display.print("Enter this IP"); display.setCursor(0, 48); display.print("in the app"); } else if (!bot.powerOn) { // OFF: same sleep face as sleeping centerText("JsConnect", 8, 1); display.fillRect(EYE_X_L, EYE_Y + 28, BASE_EYE_W, 3, WHITE); display.fillRect(EYE_X_R, EYE_Y + 28, BASE_EYE_W, 3, WHITE); display.fillRect(64 - 8, MOUTH_Y + 10, 16, 3, WHITE); } else { if (!bot.isSleeping) { if (bot.faceMode == 0) updateAliveAnimations(); if (bot.faceMode == 1) updateHeartbeat(); } if (bot.isSleeping) { centerText("JsConnect", 8, 1); display.fillRect(EYE_X_L, EYE_Y + 28, BASE_EYE_W, 3, WHITE); display.fillRect(EYE_X_R, EYE_Y + 28, BASE_EYE_W, 3, WHITE); display.fillRect(64 - 8, MOUTH_Y + 10, 16, 3, WHITE); } else if (bot.faceMode <= 4) { drawEyes(); drawMouth(); } else if (bot.faceMode == 5) { showClock(); } else if (bot.faceMode == 6) { showTempScreen(); } else if (bot.faceMode == 7) { showHumScreen(); } else if (bot.faceMode == 8) { showPM25Screen(); } } display.display(); delay(16); } // ============== INPUT SYSTEM ==================== void handleInput() { bool touch = digitalRead(TOUCH_PIN); unsigned long now = millis(); // On first touch: only record timing and interaction, don't wake yet if (touch && !isTouching) { isTouching = true; touchStartTime = now; lastInteractionTime = now; } // Long press detection if (touch && isTouching) { if (!isLongPressing && (now - touchStartTime > LONG_PRESS_TIME)) { isLongPressing = true; triggerLongPressAction(); } } // Touch released if (!touch && isTouching) { isTouching = false; if (isLongPressing) { isLongPressing = false; releaseLongPress(); } else { tapCount++; lastTapTime = now; } } // Tap classification after delay if (!touch && tapCount > 0 && (now - lastTapTime > DOUBLE_TAP_DELAY)) { if (tapCount >= 10) { devMode = true; devModeLoop(); } else if (tapCount >= 3) { // triple tap: hardware sleep toggle if (bot.isSleeping) { bot.isSleeping = false; bot.powerOn = true; isDriftingOff = false; targetEyeOpenFactor = 1.0; bot.faceMode = 0; lastInteractionTime = millis(); } else { bot.isSleeping = true; bot.powerOn = false; bot.faceMode = 0; } } else if (tapCount == 1) { if (!bot.isSleeping) { bot.faceMode++; if (bot.faceMode > 8) bot.faceMode = 0; isPuppySquint = false; bot.isBeingPetted = false; isRejected = false; bot.isFurious = false; bot.isComforted = false; isDriftingOff = false; hasMidYawned = false; hasFinalYawned = false; currentEyeOpenFactor = targetEyeOpenFactor = 1.0; lastInteractionTime = millis(); } } tapCount = 0; } } void triggerLongPressAction() { if (bot.faceMode == 0) bot.isBeingPetted = true; if (bot.faceMode == 2) bot.isFurious = true; if (bot.faceMode == 3) bot.isComforted = true; } void releaseLongPress() { bot.isBeingPetted = false; bot.isFurious = false; bot.isComforted = false; } // ========== ANIMATION LOGIC ===================== void triggerYawn(int duration) { if (bot.isBeingPetted) return; isYawning = true; yawnEndTime = millis() + duration; targetYawnFactor = 1.0; } void updateHeartbeat() { float beat = sin(millis() * 0.015); if (beat > 0.8) heartScale = 1.2; else if (beat > 0.0) heartScale = 1.0 + (beat * 0.2); else heartScale = 1.0; } void updateAliveAnimations() { unsigned long now = millis(); if (isPuppySquint && now > squintEndTime) isPuppySquint = false; if (isRejected && now > rejectEndTime) isRejected = false; if (isYawning) { if (now > yawnEndTime) { isYawning = false; targetYawnFactor = 0.0; } else { targetYawnFactor = 1.0; } } bool canLook = !isDriftingOff && currentYawnFactor < 0.1 && !isYawning && !bot.isBeingPetted && !isPuppySquint; if (canLook) { if (now > nextLookTime) { int r = random(0, 10); if (r < 6) { lookDirection = 0; nextLookTime = now + random(2000, 5000); } else if (r < 8) { lookDirection = -1; nextLookTime = now + 600; } else { lookDirection = 1; nextLookTime = now + 600; } } if (lookDirection == 0) { targetEyeW_L = BASE_EYE_W; targetEyeW_R = BASE_EYE_W; targetMouthX = 0; } else if (lookDirection == -1) { targetEyeW_L = BASE_EYE_W - 14; targetEyeW_R = BASE_EYE_W + 14; targetMouthX = -15; } else { targetEyeW_L = BASE_EYE_W + 14; targetEyeW_R = BASE_EYE_W - 14; targetMouthX = 15; } } else { targetEyeW_L = BASE_EYE_W; targetEyeW_R = BASE_EYE_W; targetMouthX = 0; } auto moveTowards = [](float current, float target, float speed) { if (fabs(current - target) < speed) return target; if (current < target) return current + speed; return current - speed; }; currentEyeW_L = moveTowards(currentEyeW_L, targetEyeW_L, PAN_SPEED); currentEyeW_R = moveTowards(currentEyeW_R, targetEyeW_R, PAN_SPEED); currentMouthX = moveTowards(currentMouthX, targetMouthX, PAN_SPEED); currentYawnFactor = moveTowards(currentYawnFactor, targetYawnFactor, YAWN_SPEED); currentEyeOpenFactor = moveTowards(currentEyeOpenFactor, targetEyeOpenFactor, SLEEP_SPEED); if (!isDriftingOff && !bot.isBeingPetted && currentYawnFactor < 0.1) { if (now - lastBlinkTime > 1000) { isBlinking = true; if (now - lastBlinkTime > 1150) { isBlinking = false; lastBlinkTime = now; } } } } // ========== DRAWING HELPERS ===================== void drawHeart(int x, int y, float scale) { int r = (int)(8 * scale); int offX = (int)(8 * scale); int offY = (int)(5 * scale); int triH = (int)(16 * scale); display.fillCircle(x - offX, y - offY, r, WHITE); display.fillCircle(x + offX, y - offY, r, WHITE); display.fillTriangle(x - (r * 2), y - offY, x + (r * 2), y - offY, x, y + triH, WHITE); } void drawSingleTear(int x, int y) { display.fillCircle(x, y, 2, WHITE); display.fillTriangle(x - 1, y, x + 1, y, x, y - 5, WHITE); } void drawTears() { if (bot.isComforted) return; int startY = 38; for (int i = 0; i < 8; i++) { int dropOffset = (int)(tearY + i * 8) % 28; int currentY = startY + dropOffset; int wobble = (int)(sin((tearY + i) * 0.4) * 2); if (dropOffset < 26) { drawSingleTear(31 + wobble, currentY); drawSingleTear(97 - wobble, currentY); } } tearY += 0.6; } void drawSpiral(int centerX, int centerY, int direction, float rotationOffset) { float angle = 0; float radius = 0; while (radius < 14) { float effectiveAngle = (angle + rotationOffset) * direction; int x = centerX + (int)(cos(effectiveAngle) * radius); int y = centerY + (int)(sin(effectiveAngle) * radius); display.drawPixel(x, y, WHITE); display.drawPixel(x+1, y, WHITE); angle += 0.4; radius += 0.25; } } void drawAngryFire(int centerX, int bottomY) { int frame = (millis() / 100) % 2; if (frame == 0) { display.fillTriangle(centerX - 6, bottomY, centerX + 6, bottomY, centerX, bottomY - 14, WHITE); display.fillTriangle(centerX - 9, bottomY - 2, centerX - 5, bottomY - 2, centerX - 7, bottomY - 8, WHITE); display.fillTriangle(centerX + 5, bottomY - 2, centerX + 9, bottomY - 2, centerX + 7, bottomY - 8, WHITE); } else { display.fillTriangle(centerX - 7, bottomY, centerX + 7, bottomY, centerX, bottomY - 16, WHITE); display.fillTriangle(centerX - 11, bottomY - 4, centerX - 7, bottomY - 4, centerX - 9, bottomY - 10, WHITE); display.fillTriangle(centerX + 7, bottomY - 4, centerX + 11, bottomY - 4, centerX + 9, bottomY - 10, WHITE); } } void drawEyes() { if (bot.faceMode == 0) { int wL = (int)(currentEyeW_L + 0.5); int wR = (int)(currentEyeW_R + 0.5); float h = EYE_H; if (currentYawnFactor > 0) { h = map((int)(currentYawnFactor * 100), 0, 100, EYE_H, 6); } h *= currentEyeOpenFactor; if (isPuppySquint) h = 10; if (bot.isBeingPetted) h = 4; if (isBlinking && !isDriftingOff && !bot.isBeingPetted) h = 4; if (h < 2 && currentEyeOpenFactor > 0.1) h = 2; int finalH = (int)h; int l_x = (EYE_X_L + BASE_EYE_W / 2) - wL / 2; int r_x = (EYE_X_R + BASE_EYE_W / 2) - wR / 2; int l_y = EYE_Y + (EYE_H - finalH) / 2; int r_y = EYE_Y + (EYE_H - finalH) / 2; if (finalH <= 6) { display.fillRect(l_x, l_y, wL, finalH, WHITE); display.fillRect(r_x, r_y, wR, finalH, WHITE); } else { display.fillRoundRect(l_x, l_y, wL, finalH, EYE_RADIUS, WHITE); display.fillRoundRect(r_x, r_y, wR, finalH, EYE_RADIUS, WHITE); } return; } if (bot.faceMode == 2) { int shakeX = 0; int shakeY = 0; int angryOffset = 0; int eyebrowHeight = 4; if (bot.isFurious) { shakeX = random(-2, 3); shakeY = random(-2, 3); eyebrowHeight = 12; drawAngryFire(64 + shakeX, EYE_Y + 13 + shakeY); } if (isRejected) angryOffset = -15; display.fillRoundRect(EYE_X_L + shakeX + angryOffset, EYE_Y + 18 + shakeY, BASE_EYE_W, EYE_H - 18, 6, WHITE); display.fillRect(EYE_X_L + shakeX + angryOffset, EYE_Y + 18 + shakeY, BASE_EYE_W, eyebrowHeight, BLACK); display.fillRoundRect(EYE_X_R + shakeX + angryOffset, EYE_Y + 18 + shakeY, BASE_EYE_W, EYE_H - 18, 6, WHITE); display.fillRect(EYE_X_R + shakeX + angryOffset, EYE_Y + 18 + shakeY, BASE_EYE_W, eyebrowHeight, BLACK); return; } switch (bot.faceMode) { case 1: drawHeart(EYE_X_L + BASE_EYE_W / 2, EYE_Y + EYE_H / 2, heartScale); drawHeart(EYE_X_R + BASE_EYE_W / 2, EYE_Y + EYE_H / 2, heartScale); break; case 3: display.fillRect(EYE_X_L, EYE_Y + 28, BASE_EYE_W, 5, WHITE); display.fillRect(EYE_X_R, EYE_Y + 28, BASE_EYE_W, 5, WHITE); drawTears(); break; case 4: drawSpiral(EYE_X_L + BASE_EYE_W / 2, EYE_Y + EYE_H / 2, -1, spiralAngle); drawSpiral(EYE_X_R + BASE_EYE_W / 2, EYE_Y + EYE_H / 2, 1, spiralAngle); spiralAngle += 0.3; break; } } void drawMouth() { int centerX = 64; if (bot.faceMode == 0) { int x = centerX + (int)(currentMouthX + 0.5); if (bot.isBeingPetted) { display.fillCircle(x, MOUTH_Y + 2, 12, WHITE); display.fillCircle(x, MOUTH_Y - 2, 12, BLACK); return; } if (currentYawnFactor > 0.05) { int yW = 16 + (int)(currentYawnFactor * 4); int yH = (int)(currentYawnFactor * 18); display.fillRoundRect(x - yW / 2, MOUTH_Y + 5 - yH / 2, yW, yH + 5, 5, WHITE); display.fillRoundRect(x - yW / 2 + 2, MOUTH_Y + 5 - yH / 2 + 2, yW - 4, yH + 5 - 4, 3, BLACK); } else { display.fillCircle(x, MOUTH_Y + 5, 9, WHITE); display.fillCircle(x, MOUTH_Y + 1, 9, BLACK); } return; } if (bot.faceMode == 2) { int shakeX = 0; if (bot.isFurious) shakeX = random(-2, 3); int angryOffset = 0; if (isRejected) angryOffset = -15; display.fillRoundRect(centerX - 12 + shakeX + angryOffset, MOUTH_Y + 10, 24, 4, 1, WHITE); return; } switch (bot.faceMode) { case 1: display.fillCircle(centerX, MOUTH_Y + 5, 9, WHITE); display.fillCircle(centerX, MOUTH_Y + 1, 9, BLACK); break; case 3: if (bot.isComforted) { display.fillRect(centerX - 8, MOUTH_Y + 14, 16, 3, WHITE); } else { display.fillCircle(centerX, MOUTH_Y + 12, 9, WHITE); display.fillCircle(centerX, MOUTH_Y + 16, 9, BLACK); } break; case 4: for (int x = -14; x < 14; x++) { int yOffset = (int)(sin(x * 0.6) * 3); display.fillCircle(centerX + x, MOUTH_Y + 10 + yOffset, 1, WHITE); } break; } } // ========== CLOCK & DATA SCREENS =============== void syncTimeIST() { if (WiFi.status() != WL_CONNECTED) return; Serial.println("Syncing time with NTP servers..."); configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_S, "pool.ntp.org", "time.nist.gov"); struct tm t; for (int i = 0; i < 20; i++) { if (getLocalTime(&t)) { timeReady = true; Serial.println("Time synced successfully!"); return; } delay(500); } Serial.println("Time sync failed"); } void showClock() { struct tm t; if (!getLocalTime(&t)) { centerText("Asking servers", 25, 1); centerText("for the time...", 35, 1); return; } char dateStr[20]; strftime(dateStr, sizeof(dateStr), "%a, %d %b", &t); centerText(dateStr, 0, 1); int hr = t.tm_hour % 12; if (hr == 0) hr = 12; bool pm = (t.tm_hour >= 12); char timeStr[6]; sprintf(timeStr, "%02d:%02d", hr, t.tm_min); display.setTextSize(3); int timeWidth = strlen(timeStr) * 6 * 3; int ampmWidth = 2 * 6 * 2; int totalWidth = timeWidth + 4 + ampmWidth; int xTime = (128 - totalWidth) / 2; int yTime = 22; display.setCursor(xTime, yTime); display.print(timeStr); display.setTextSize(2); display.setCursor(xTime + timeWidth + 4, yTime + 4); display.print(pm ? "PM" : "AM"); } void showTempScreen() { display.setTextSize(1); centerText("Temperature", 0, 1); char buf[10]; if (isnan(outdoorTemp)) { strcpy(buf, "--.-"); } else { dtostrf(outdoorTemp, 4, 1, buf); } display.setTextSize(2); String tempDisplay = String(buf) + (char)247 + "C"; int fullWidth = tempDisplay.length() * 6 * 2; int xPos = (128 - fullWidth) / 2; display.setCursor(xPos, 28); display.print(tempDisplay); } void showHumScreen() { display.setTextSize(1); centerText("Humidity", 0, 1); char buf[10]; if (isnan(outdoorHum)) { strcpy(buf, "--"); } else { dtostrf(outdoorHum, 3, 0, buf); } display.setTextSize(2); String humDisplay = String(buf) + "%"; int fullWidth = humDisplay.length() * 6 * 2; int xPos = (128 - fullWidth) / 2; display.setCursor(xPos, 28); display.print(humDisplay); } void showPM25Screen() { display.setTextSize(1); centerText("PM2.5 / AQI", 0, 1); char buf[10]; if (isnan(pm25)) { strcpy(buf, "--.-"); } else { dtostrf(pm25, 4, 1, buf); } display.setTextSize(1); String pm25Display = String(buf) + " ug/m3"; int pm25Width = pm25Display.length() * 6 * 1; int xPM25 = (128 - pm25Width) / 2; display.setCursor(xPM25, 20); display.print(pm25Display); display.setTextSize(1); centerText("Status:", 32, 1); const char* cat = pm25Category(pm25); centerText(cat, 44, 1); } // ========== WEATHER / AQI FETCH ================ void fetchWeatherOWM() { if (WiFi.status() != WL_CONNECTED) return; HTTPClient http; String url = "https://api.openweathermap.org/data/2.5/weather?lat=" + String(cfg.latitude, 4) + "&lon=" + String(cfg.longitude, 4) + "&units=metric&appid=" + cfg.apiKey; http.begin(url); int httpCode = http.GET(); if (httpCode == 200) { DynamicJsonDocument doc(2048); DeserializationError err = deserializeJson(doc, http.getString()); if (!err) { outdoorTemp = doc["main"]["temp"].as(); outdoorHum = doc["main"]["humidity"].as(); weatherReady = true; lastWeatherUpdate = millis(); } } http.end(); } void fetchAQI_OWM() { if (WiFi.status() != WL_CONNECTED) return; HTTPClient http; String url = "https://api.openweathermap.org/data/2.5/air_pollution?lat=" + String(cfg.latitude, 4) + "&lon=" + String(cfg.longitude, 4) + "&appid=" + cfg.apiKey; http.begin(url); int httpCode = http.GET(); if (httpCode == 200) { DynamicJsonDocument doc(2048); DeserializationError err = deserializeJson(doc, http.getString()); if (!err) { JsonObject compObj = doc["list"][0]["components"]; pm25 = compObj["pm2_5"].as(); lastAQIUpdate = millis(); } } http.end(); } // CPCB PM2.5 bands const char* pm25Category(float pm) { if (isnan(pm)) return "Unknown"; if (pm <= 30) return "Good"; if (pm <= 60) return "Satisfactory"; if (pm <= 90) return "Moderately Polluted"; if (pm <= 120) return "Poor"; if (pm <= 250) return "Very Poor"; return "Severe"; } // ========== TEXT HELPERS ======================== void centerText(const char* txt, int y, int size) { display.setTextSize(size); int w = strlen(txt) * 6 * size; display.setCursor((128 - w) / 2, y); display.print(txt); } void showMessage(const char* msg) { display.clearDisplay(); centerText(msg, 30, 1); display.display(); }