#include #include #include #include "esp_camera.h" #include "img_converters.h" #include #include #include #include #include "wifi_config.h" // ========================= // CAMERA CONFIG (ONLY ONE) // ========================= #define CAMERA_MODEL_ESP32S3_EYE #if defined(CAMERA_MODEL_ESP32S3_EYE) #define PWDN_GPIO_NUM -1 #define RESET_GPIO_NUM -1 #define XCLK_GPIO_NUM 15 #define SIOD_GPIO_NUM 4 #define SIOC_GPIO_NUM 5 #define Y2_GPIO_NUM 11 #define Y3_GPIO_NUM 9 #define Y4_GPIO_NUM 8 #define Y5_GPIO_NUM 10 #define Y6_GPIO_NUM 12 #define Y7_GPIO_NUM 18 #define Y8_GPIO_NUM 17 #define Y9_GPIO_NUM 16 #define VSYNC_GPIO_NUM 6 #define HREF_GPIO_NUM 7 #define PCLK_GPIO_NUM 13 #else #error "Camera model not selected" #endif static constexpr int TFT_BL = 21; TFT_eSPI tft = TFT_eSPI(); static constexpr bool TFT_NEEDS_BGR_FIX = false; static uint16_t lineBuf[320]; sensor_t *gSensor = nullptr; unsigned long lastPreview = 0; // 40ms (~25 FPS) is heavy when combined with WiFi/HTTP. // 80ms (~12 FPS) is much smoother on ESP32-S3 with TFT preview. const unsigned long PREVIEW_INTERVAL_MS = 80; volatile bool gBusy = false; bool gMenuDrawn = false; // pause preview while capturing/uploading pixformat_t gCurrentPixFormat = PIXFORMAT_RGB565; // Heartbeat debug (helps confirm Serial output path + device still alive) unsigned long gLastHeartbeat = 0; unsigned long gLastReconnect = 0; static constexpr unsigned long RECONNECT_INTERVAL_MS = 30000; static constexpr unsigned long HEARTBEAT_MS = 2000; // Server connection failure counter int gServerFailureCount = 0; static constexpr int MAX_SERVER_FAILURES = 3; bool gServerDisabled = false; // If true, we accept manual commands from Serial Monitor (ENROLL/RECOG/LIVE/STOP). // For a clean system, keep this false and control via Web Jobs + RFID only. static constexpr bool ENABLE_SERIAL_COMMANDS = false; // ===== UART for ESP8266 RFID slave ===== // Use a dedicated UART so disabling Serial commands won't break RFID. // Change these pins to match your wiring (ESP8266 TX -> ESP32 RX pin). // NOTE: Avoid using GPIO44/GPIO43 (often U0RXD/U0TXD) for RFID UART on ESP32-S3, // because it can conflict with USB-Serial/JTAG console. // Set to your actual wiring, or set RX=-1 to temporarily disable RFID UART. static constexpr int RFID_RX_PIN = 44; static constexpr int RFID_TX_PIN = 43; static constexpr uint32_t RFID_BAUD = 115200; static constexpr bool ENABLE_RFID_UART = true; static inline bool rfidUartEnabled() { return ENABLE_RFID_UART && RFID_RX_PIN >= 0; } HardwareSerial RFIDSerial(1); String gRfidLineBuf; // ===== RFID handshake (from ESP8266) ===== // Expected line format from ESP8266: // RFID UID=04:AB:12:CD NAME=andi // NAME is optional. String gExpectedName = ""; String gExpectedUid = ""; unsigned long gExpectedSetAt = 0; static constexpr unsigned long EXPECTED_TTL_MS = 15000; // must do face within 15s // Camera idle timeout (2 minutes = 120000 ms) unsigned long gLastRfidInputTime = 0; static constexpr unsigned long CAMERA_IDLE_TIMEOUT_MS = 120000; // 2 minutes bool gCameraActive = false; // Camera only activates during ENROLL or RECOG // RECOG job params (from Web job queue) float gJobRecogThreshold = 0.25f; String gJobRecogUid = ""; String gJobPersonId = ""; // Pending RECOG triggered by RFID scan // When set, main loop will show camera preview then capture after countdown // Auto-retry RECOG / ENROLL state String gKeepTryingCmd = ""; bool gIsWebJob = false; String gLastTapInfo = ""; unsigned long gKeepTryingUntil = 0; bool gLastActionSuccess = false; unsigned long gNextCaptureTime = 0; static bool isDigits(const String &str) { if (str.length() == 0) return false; for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); if (c < '0' || c > '9') return false; } return true; } camera_config_t makeCameraConfig(pixformat_t fmt) { camera_config_t config; config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM; config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM; config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM; config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM; config.pin_xclk = XCLK_GPIO_NUM; config.pin_pclk = PCLK_GPIO_NUM; config.pin_vsync = VSYNC_GPIO_NUM; config.pin_href = HREF_GPIO_NUM; config.pin_sccb_sda = SIOD_GPIO_NUM; config.pin_sccb_scl = SIOC_GPIO_NUM; config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM; config.xclk_freq_hz = 20000000; config.pixel_format = fmt; config.frame_size = FRAMESIZE_QVGA; // 320x240 config.jpeg_quality = 12; config.fb_count = psramFound() ? 2 : 1; config.grab_mode = CAMERA_GRAB_LATEST; config.fb_location = psramFound() ? CAMERA_FB_IN_PSRAM : CAMERA_FB_IN_DRAM; return config; } bool reinitCamera(pixformat_t fmt) { if (gCurrentPixFormat == fmt && gSensor != nullptr) return true; // Stop current camera driver (safe to call even if not running). esp_camera_deinit(); delay(50); camera_config_t config = makeCameraConfig(fmt); Serial.println("\n[DEBUG] Starting camera init..."); Serial.printf("[DEBUG] Pixel format: %d, FB location: %s\n", fmt, psramFound() ? "PSRAM" : "DRAM"); esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { Serial.printf("[ERROR] Camera init failed: 0x%x (%s)\n", err, esp_err_to_name(err)); Serial.println("[DEBUG] Camera config:"); Serial.printf(" xclk_freq_hz=%u frame_size=%d jpeg_quality=%d fb_count=%d fb_location=%s\n", config.xclk_freq_hz, config.frame_size, config.jpeg_quality, config.fb_count, config.fb_location == CAMERA_FB_IN_PSRAM ? "PSRAM" : "DRAM"); gSensor = nullptr; return false; } Serial.println("[SUCCESS] Camera init OK"); sensor_t *s = esp_camera_sensor_get(); if (s) { s->set_brightness(s, 0); s->set_contrast(s, 0); s->set_saturation(s, 0); gSensor = s; Serial.println("[SUCCESS] Sensor configured"); } else { Serial.println("[WARNING] Sensor pointer null"); } gCurrentPixFormat = fmt; return true; } // ===== WiFi & Server Config ===== #include #include "wifi_config.h" WebServer server(80); String SERVER_BASE; String httpGet(const String &url, uint32_t timeoutMs); // ===== Web job polling (simple control via dashboard buttons) ===== static constexpr bool ENABLE_JOB_POLL = true; unsigned long lastJobPoll = 0; // Polling too often makes preview stutter. 2500ms still feels responsive. const unsigned long JOB_POLL_INTERVAL_MS = 2000; bool initCamera() { // Default for preview to TFT return reinitCamera(PIXFORMAT_RGB565); } String gOverlayMsg = ""; String gOverlayRaw = ""; uint16_t gOverlayColor = TFT_WHITE; unsigned long gOverlayExpire = 0; void showStatus(const String &line, uint16_t color = TFT_GREEN, const String &raw = "", int durationMs = 3500) { gOverlayMsg = line; gOverlayRaw = raw; gOverlayColor = color; gOverlayExpire = millis() + durationMs; Serial.println("[STATUS] " + line + (raw.length() > 0 ? " | RAW: " + raw : "")); } void drawOverlayMessage() { if (millis() > gOverlayExpire || gOverlayMsg.length() == 0) return; // --- Layout constants for 320x240 TFT --- const int SCREEN_W = 320; const int SCREEN_H = 240; const int MARGIN = 8; // padding inside box const int BOX_W = SCREEN_W - 16; // 304px wide, 8px gap each side const int BOX_X = 8; // --- Split primary message on '\n' into up to 2 lines --- String line1 = gOverlayMsg; String line2 = ""; int nl = gOverlayMsg.indexOf('\n'); if (nl >= 0) { line1 = gOverlayMsg.substring(0, nl); line2 = gOverlayMsg.substring(nl + 1); } // Truncate each line to fit textSize(2): 12px/char → 304/12 ≈ 25 chars max const int MAX_LINE_CHARS = 25; if ((int)line1.length() > MAX_LINE_CHARS) line1 = line1.substring(0, MAX_LINE_CHARS - 1) + "~"; if ((int)line2.length() > MAX_LINE_CHARS) line2 = line2.substring(0, MAX_LINE_CHARS - 1) + "~"; // --- Calculate box height --- // textSize(2): line height ~18px int primaryLines = (line2.length() > 0) ? 2 : 1; int boxH = MARGIN + primaryLines * 18; // Prepare raw (subtitle) text String rawText = gOverlayRaw; rawText.trim(); // textSize(1): 6px/char → 304/6 ≈ 50 chars per raw line, use 40 to be safe const int MAX_RAW_CHARS = 40; int rawLineCount = 0; if (rawText.length() > 0) { boxH += 6; // gap between primary and raw rawLineCount = ((int)rawText.length() + MAX_RAW_CHARS - 1) / MAX_RAW_CHARS; if (rawLineCount > 3) rawLineCount = 3; // cap at 3 lines boxH += rawLineCount * 11; // textSize(1) line height ~10px + 1px gap } boxH += MARGIN; // bottom padding // Clamp box height to screen if (boxH > SCREEN_H - 16) boxH = SCREEN_H - 16; int boxY = (SCREEN_H - boxH) / 2; // --- Draw box --- tft.fillRect(BOX_X, boxY, BOX_W, boxH, TFT_BLACK); tft.drawRect(BOX_X, boxY, BOX_W, boxH, gOverlayColor); tft.drawRect(BOX_X + 1, boxY + 1, BOX_W - 2, boxH - 2, gOverlayColor); // --- Draw primary message --- tft.setTextSize(2); tft.setTextColor(gOverlayColor, TFT_BLACK); int yy = boxY + MARGIN; tft.setCursor(BOX_X + MARGIN, yy); tft.print(line1); if (line2.length() > 0) { yy += 18; tft.setCursor(BOX_X + MARGIN, yy); tft.print(line2); } // --- Draw subtitle (raw) --- if (rawText.length() > 0) { yy += 22; // gap after primary tft.setTextSize(1); tft.setTextColor(TFT_WHITE, TFT_BLACK); int drawn = 0; while (rawText.length() > 0 && drawn < 3) { String chunk = rawText.substring(0, min(MAX_RAW_CHARS, (int)rawText.length())); tft.setCursor(BOX_X + MARGIN, yy); tft.print(chunk); rawText = (rawText.length() > MAX_RAW_CHARS) ? rawText.substring(MAX_RAW_CHARS) : ""; yy += 11; drawn++; } } } void connectWiFi() { if (config.ssid == "") { Serial.println("SSID kosong"); return; } WiFi.mode(WIFI_STA); WiFi.begin( config.ssid.c_str(), config.password.c_str()); WiFi.setSleep(false); Serial.print("Connecting WiFi"); unsigned long start = millis(); while ( WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { Serial.println("WiFi Connected"); Serial.println(WiFi.localIP()); SERVER_BASE = config.serverURL; Serial.println("TEST API..."); String testResp = httpGet( SERVER_BASE + "/", 5000); Serial.println(testResp); if ( testResp.startsWith("HTTP") || testResp.startsWith("{")) { Serial.println("API CONNECTED"); showStatus("API OK", TFT_GREEN); } else { Serial.println("API FAILED"); showStatus("SERVER DISABLE/RTO", TFT_RED, ""); } Serial.println(SERVER_BASE); showStatus("WiFi OK", TFT_GREEN); } else { Serial.println("WiFi Failed"); showStatus("WIFI FAIL", TFT_RED, "Timeout 15s"); } } // ========================= // AP MODE // ========================= void startAPMode() { WiFi.mode(WIFI_AP); WiFi.softAP( "ESP32S3-CAMERA", "12345678"); Serial.println("AP MODE"); Serial.println(WiFi.softAPIP()); showStatus( "AP MODE\n192.168.4.1", TFT_CYAN); } // ========================= // ROOT PAGE // ========================= void handleRoot() { String html = R"rawliteral( ESP32S3 CAMERA

ESP32 CONFIG

Form konfigurasi telah dipindah ke Web UI Utama.

Silakan buka tab "Config ESP32" di Web UI Utama dan masukkan IP ESP32 ini untuk merubah pengaturan.

)rawliteral"; server.send(200, "text/html", html); } // ========================= // SAVE CONFIG // ========================= void handleSave() { config.ssid = server.arg("ssid"); config.password = server.arg("password"); config.serverURL = server.arg("url"); saveConfig(); server.send( 200, "text/plain", "CONFIG SAVED"); delay(2000); ESP.restart(); } // ========================= // RESET SERVER CONNECTION // ========================= void handleResetServer() { gServerFailureCount = 0; gServerDisabled = false; Serial.println("[SERVER] Connection re-enabled via web"); server.send(200, "text/plain", "SERVER CONNECTION RESET"); } // Lightweight overlay to avoid wiping camera preview. void showOverlay(const String &text, uint16_t color) { // Draw a small badge at top-left. tft.fillRect(0, 0, tft.width(), 22, TFT_BLACK); tft.setTextSize(1); tft.setCursor(2, 2); tft.setTextColor(color, TFT_BLACK); tft.print(text); } // Show a small single-line text just below the top overlay badge void showSmallBelowTop(const String &text, uint16_t color) { const int topBadgeH = 22; const int h = 18; tft.fillRect(0, topBadgeH, tft.width(), h, TFT_BLACK); tft.setTextSize(1); tft.setCursor(2, topBadgeH + 2); tft.setTextColor(color, TFT_BLACK); tft.print(text); } static inline uint16_t swapRB565(uint16_t c) { uint16_t r = (c >> 11) & 0x1F; uint16_t g = (c >> 5) & 0x3F; uint16_t b = c & 0x1F; return static_cast((b << 11) | (g << 5) | r); } void drawFrameToTFT(camera_fb_t *fb) { if (!fb || fb->format != PIXFORMAT_RGB565) return; const int camW = fb->width; const int camH = fb->height; const int tftW = tft.width(); const int tftH = tft.height(); const int drawW = min(camW, tftW); const int drawH = min(camH, tftH); const int srcX = (camW - drawW) / 2; const int srcY = (camH - drawH) / 2; const int dstX = (tftW - drawW) / 2; const int dstY = (tftH - drawH) / 2; const uint8_t *buf = fb->buf; for (int y = 0; y < drawH; y++) { const int srcRow = y + srcY; const uint8_t *rowPtr = buf + ((srcRow * camW + srcX) * 2); for (int x = 0; x < drawW; x++) { uint16_t c = (static_cast(rowPtr[x * 2]) << 8) | rowPtr[x * 2 + 1]; lineBuf[x] = TFT_NEEDS_BGR_FIX ? swapRB565(c) : c; } tft.pushImage(dstX, dstY + y, drawW, 1, lineBuf); } } void setPixelFormat(pixformat_t fmt) { // For this sketch, we prefer a reliable format change. // Some boards don't apply set_pixformat reliably while streaming, // so we reinit the camera driver. reinitCamera(fmt); } String postFrame(const String &url, uint8_t *buf, size_t len, uint32_t timeoutMs) { if (!buf || len == 0) return "ERROR no_frame"; if (WiFi.status() != WL_CONNECTED) { return "WIFI DISCONNECTED"; } WiFiClientSecure client; client.setInsecure(); HTTPClient http; bool ok = http.begin(client, url); if (!ok) { return "HTTP BEGIN FAIL"; } http.addHeader("Content-Type", "image/jpeg"); http.addHeader("ngrok-skip-browser-warning", "true"); http.addHeader("User-Agent", "ESP32-Camera/1.0"); http.setTimeout(timeoutMs); int code = http.POST(buf, len); // Avoid huge heap allocations from big responses; // FastAPI returns JSON, but we only need a short status. String payload; if (code > 0) { payload = http.getString(); if (payload.length() > 200) payload = payload.substring(0, 200); } http.end(); if (code <= 0) { // Connection / DNS / timeout errors are negative. return String("HTTP ERR ") + code; } // Server responded but with error status. if (code < 200 || code >= 300) { if (payload.length() == 0) return String("HTTP ") + code; return String("HTTP ") + code + " " + payload; } if (payload.length() == 0) { return String("HTTP ") + code; } return payload; } // Wrapper: try once more if we got common transient errors (timeout / connect fail). String postFrameWithRetry(const String &url, uint8_t *buf, size_t len, uint32_t timeoutMs, int retries = 1) { String resp = postFrame(url, buf, len, timeoutMs); while (retries-- > 0) { if (resp == "HTTP ERR -11" || resp == "HTTP ERR -1" || resp == "HTTP ERR -4") { delay(250); resp = postFrame(url, buf, len, timeoutMs); continue; } break; } return resp; } String httpPostNoBody(const String &url, uint32_t timeoutMs) { if (WiFi.status() != WL_CONNECTED) { return "WIFI DISCONNECTED"; } // Retry logic untuk mengatasi timeout int maxRetries = 3; for (int attempt = 0; attempt < maxRetries; attempt++) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; bool ok = http.begin(client, url); if (!ok) continue; http.addHeader("ngrok-skip-browser-warning", "true"); http.addHeader("User-Agent", "ESP32-Camera/1.0"); uint32_t adjustedTimeout = timeoutMs + (attempt * 2000); http.setTimeout(adjustedTimeout); int code = http.POST((uint8_t *)nullptr, 0); String payload; if (code > 0) { payload = http.getString(); if (payload.length() > 200) payload = payload.substring(0, 200); } http.end(); if (code > 0) { if (code < 200 || code >= 300) return String("HTTP ") + code + (payload.length() ? String(" ") + payload : ""); return payload.length() ? payload : String("HTTP ") + code; } if (attempt < maxRetries - 1) { delay(500 + (attempt * 500)); } } return String("HTTP ERR -5"); } String httpGet(const String &url, uint32_t timeoutMs) { if (WiFi.status() != WL_CONNECTED) { return "WIFI DISCONNECTED"; } // Retry logic untuk mengatasi timeout (-5) dan connection fail (-1, -11) int maxRetries = 3; for (int attempt = 0; attempt < maxRetries; attempt++) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; bool ok = http.begin(client, url); if (!ok) continue; http.addHeader("ngrok-skip-browser-warning", "true"); http.addHeader("User-Agent", "ESP32-Camera/1.0"); // Tingkatkan timeout untuk koneksi lambat uint32_t adjustedTimeout = timeoutMs + (attempt * 2000); http.setTimeout(adjustedTimeout); int code = http.GET(); String payload; if (code > 0) { payload = http.getString(); if (payload.length() > 800) payload = payload.substring(0, 800); } http.end(); // Jika berhasil, return hasil if (code > 0) { if (code < 200 || code >= 300) return String("HTTP ") + code + (payload.length() ? String(" ") + payload : ""); return payload.length() ? payload : String("HTTP ") + code; } // Jika error koneksi, retry dengan delay if (attempt < maxRetries - 1) { delay(500 + (attempt * 500)); Serial.printf("[HTTP] Retry %d - Err %d\n", attempt + 1, code); } } return String("HTTP ERR -5 (timeout/koneksi)"); } // Minimal JSON string extraction: find string value for key (e.g., "type","name"). String jsonGetString(const String &json, const char *key) { String pat = String('"') + key + String('"'); int k = json.indexOf(pat); if (k < 0) return ""; int colon = json.indexOf(':', k + pat.length()); if (colon < 0) return ""; int q1 = json.indexOf('"', colon); if (q1 < 0) return ""; int q2 = json.indexOf('"', q1 + 1); if (q2 < 0) return ""; String val = json.substring(q1 + 1, q2); val.trim(); return val; } String urlEncode(const String &str) { String encoded = ""; for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') { encoded += c; } else if (c == ' ') { encoded += "%20"; } else { char buf[4]; sprintf(buf, "%%%02X", (unsigned char)c); encoded += buf; } } return encoded; } float jsonGetFloat(const String &json, const char *key, float defVal) { String pat = String('"') + key + String('"'); int k = json.indexOf(pat); if (k < 0) return defVal; int colon = json.indexOf(':', k + pat.length()); if (colon < 0) return defVal; int start = colon + 1; while (start < (int)json.length() && (json[start] == ' ' || json[start] == '\n' || json[start] == '\r' || json[start] == '\t')) start++; int end = start; while (end < (int)json.length()) { char c = json[end]; if ((c >= '0' && c <= '9') || c == '.' || c == '-') { end++; continue; } break; } if (end <= start) return defVal; return json.substring(start, end).toFloat(); } bool jsonGetBool(const String &json, const char *key, bool defVal) { String pat = String('"') + key + String('"'); int k = json.indexOf(pat); if (k < 0) return defVal; int colon = json.indexOf(':', k + pat.length()); if (colon < 0) return defVal; int start = colon + 1; while (start < (int)json.length() && (json[start] == ' ' || json[start] == '\n' || json[start] == '\r' || json[start] == '\t')) start++; // Accept true/false JSON or Python-style True/False. if (json.startsWith("true", start) || json.startsWith("True", start)) return true; if (json.startsWith("false", start) || json.startsWith("False", start)) return false; return defVal; } bool jsonHasNullJob(const String &json) { // response: {"ok":true,"job":null} int p = json.indexOf("\"job\""); if (p < 0) return false; int n = json.indexOf("null", p); return n >= 0; } String postJson(const String &url, const String &jsonBody, uint32_t timeoutMs) { if (WiFi.status() != WL_CONNECTED) return "WIFI DISCONNECTED"; if (url.length() == 0 || url == "/") return "NO URL"; WiFiClientSecure client; client.setInsecure(); HTTPClient http; bool ok = http.begin(client, url); if (!ok) return "HTTP BEGIN FAIL"; http.addHeader("Content-Type", "application/json"); http.addHeader("ngrok-skip-browser-warning", "true"); http.addHeader("User-Agent", "ESP32-Camera/1.0"); http.setTimeout(timeoutMs); int code = http.POST((uint8_t *)jsonBody.c_str(), jsonBody.length()); String payload; if (code > 0) { payload = http.getString(); if (payload.length() > 200) payload = payload.substring(0, 200); } http.end(); if (code <= 0) return String("HTTP ERR ") + code; if (code < 200 || code >= 300) return String("HTTP ") + code + (payload.length() ? String(" ") + payload : ""); return payload.length() ? payload : String("HTTP ") + code; } bool handleCommand(const String &cmd); void postJobResult(bool ok) { if (!gIsWebJob) return; String type = gKeepTryingCmd.startsWith("ENROLL") ? "ENROLL" : "RECOG"; String name = type == "ENROLL" ? gKeepTryingCmd.substring(7) : ""; String result = "{\"from\":\"esp32\",\"type\":\"" + type + "\",\"ok\":" + (ok ? "true" : "false"); if (name.length()) result += ",\"name\":\"" + name + "\""; if (gJobRecogUid.length()) result += ",\"uid\":\"" + gJobRecogUid + "\""; if (gJobPersonId.length()) result += ",\"person_id\":\"" + gJobPersonId + "\""; if (type == "RECOG") result += ",\"threshold\":" + String(gJobRecogThreshold, 2); result += "}"; String jr = postJson(String(SERVER_BASE) + "/job/result", result, 6000); Serial.println(String("[JOB] result post=") + jr); } // ===== BACKGROUND UPLOAD TASK ===== volatile bool gUploadTaskBusy = false; volatile bool gUploadFinished = false; uint8_t *gUploadBuf = NULL; size_t gUploadLen = 0; String gUploadUrl = ""; String gUploadCmd = ""; String gUploadResult = ""; TaskHandle_t UploadTaskHandle = NULL; void uploadTask(void *pvParameters) { while (true) { if (gUploadTaskBusy && !gUploadFinished) { gUploadResult = postFrameWithRetry(gUploadUrl, gUploadBuf, gUploadLen, 60000); if (gUploadBuf != NULL) { free(gUploadBuf); gUploadBuf = NULL; } gUploadFinished = true; } vTaskDelay(50 / portTICK_PERIOD_MS); // yield } } void pollAndRunJob() { if (SERVER_BASE.length() == 0) { showOverlay("no server", TFT_RED); return; } // Check if server is disabled due to too many failures if (gServerDisabled) { showOverlay("SERVER DISABLE/RTO", TFT_RED); return; } // Don't clear the screen during polling; keep preview stable. showOverlay("poll...", TFT_DARKGREY); String url = String(SERVER_BASE) + "/job/next?device=esp32"; String resp = httpGet(url, 10000); // Naikkan timeout ke 10 detik if (resp.startsWith("HTTP ERR") || resp.startsWith("HTTP 4") || resp.startsWith("HTTP 5") || resp.startsWith("WIFI")) { gServerFailureCount++; Serial.println(String("[POLL] Error: ") + resp + String(" (failure ") + gServerFailureCount + String("/") + MAX_SERVER_FAILURES + String(")")); if (gServerFailureCount >= MAX_SERVER_FAILURES) { gServerDisabled = true; showOverlay("SERVER DISABLE/RTO", TFT_RED); Serial.println("[POLL] Server disabled after 3 failures!"); } else { showOverlay("SERVER DISABLE/RTO", TFT_RED); } // keep quiet return; } // Reset failure counter on successful connection gServerFailureCount = 0; if (jsonHasNullJob(resp)) { // no job showOverlay("idle", TFT_DARKGREY); return; } String type = jsonGetString(resp, "type"); String name = jsonGetString(resp, "name"); float thr = jsonGetFloat(resp, "threshold", 0.25f); String uid = jsonGetString(resp, "uid"); String personId = jsonGetString(resp, "person_id"); if (personId.length() && !isDigits(personId)) { Serial.println(String("[JOB] invalid person_id from server:") + personId); personId = ""; } gJobPersonId = personId; if (type == "ENROLL") { Serial.println(String("[JOB] ENROLL start name=") + name); gKeepTryingCmd = String("ENROLL ") + name; gKeepTryingUntil = millis() + 15000; gLastActionSuccess = false; gIsWebJob = true; // Tunda capture pertama selama 3 detik agar pengguna bisa melihat preview wajahnya gNextCaptureTime = millis() + 3000; gCameraActive = true; return; } if (type == "RECOG") { gJobRecogThreshold = thr; gJobRecogUid = uid; gJobPersonId = personId; Serial.println(String("[JOB] RECOG start thr=") + String(thr, 2) + String(" uid=") + uid); gKeepTryingCmd = "RECOG"; gKeepTryingUntil = millis() + 15000; gLastActionSuccess = false; gIsWebJob = true; // Tunda capture pertama selama 3 detik agar pengguna bisa melihat preview wajahnya gNextCaptureTime = millis() + 3000; gCameraActive = true; return; } } bool handleCommand(const String &cmd) { // From ESP8266 RFID slave. if (cmd.startsWith("RFID ")) { // Debounce / Block: Abaikan scan jika sedang proses (gKeepTryingCmd/gBusy) // atau jika belum lewat 5 detik sejak scan terakhir untuk mencegah spam HTTP. if (gKeepTryingCmd.length() > 0 || gBusy || (millis() - gLastRfidInputTime < 5000)) { Serial.println("[RFID] Ignored: System busy or debounce timeout"); return true; } // Update last RFID input time gLastRfidInputTime = millis(); // Note: gCameraActive is NOT set here - camera only activates when RECOG runs // Parse known tokens. // Keep it simple and tolerant. int uidPos = cmd.indexOf("UID="); int namePos = cmd.indexOf("NAME="); String uid = ""; String name = ""; if (uidPos >= 0) { int start = uidPos + 4; int end = cmd.indexOf(' ', start); uid = (end >= 0) ? cmd.substring(start, end) : cmd.substring(start); uid.trim(); } if (namePos >= 0) { int start = namePos + 5; int end = cmd.indexOf(' ', start); name = (end >= 0) ? cmd.substring(start, end) : cmd.substring(start); name.trim(); } gExpectedUid = uid; // We'll try to resolve UID->name from backend for display. // Expectation for face validation should be a PERSON NAME, not UID. gExpectedName = ""; gExpectedSetAt = millis(); if (uid.length()) showStatus("RFID DIBACA", TFT_CYAN, uid); else showStatus("FORMAT RFID\nTIDAK VALID", TFT_YELLOW, ""); // no serial echo (avoid UART noise / delays) // Resolve UID -> name from backend // Backend response: {"ok":true,"uid":"04:AB...","registered":true,"name":"Andi"} if (uid.length()) { String lookUrl = String(SERVER_BASE) + "/rfid/lookup?uid=" + uid; String lookResp = httpGet(lookUrl, 8000); if (lookResp.startsWith("HTTP ERR") || lookResp.startsWith("HTTP 4") || lookResp.startsWith("HTTP 5") || lookResp.startsWith("WIFI")) { // Server error / timeout Serial.println(String("RFID_LOOKUP_FAIL ") + lookResp); if (lookResp.startsWith("HTTP") || lookResp.startsWith("WIFI")) showStatus("SERVER DISABLE/RTO", TFT_RED, ""); else showStatus("RFID GAGAL", TFT_RED, lookResp); return true; } String resolved = jsonGetString(lookResp, "name"); resolved.trim(); bool registered = jsonGetBool(lookResp, "registered", false); Serial.println(String("RFID: registered=") + (registered ? "true" : "false") + " name=" + resolved); // Simpan info person untuk ditampilkan di bawah menu gLastTapInfo = uid + (resolved.length() ? " (" + resolved + ")" : ""); // Langsung gambar ke layar agar terlihat secara instan tft.fillRect(0, 205, tft.width(), 35, TFT_BLACK); tft.setTextSize(1); tft.setTextColor(0x07FF, TFT_BLACK); // CYAN tft.setCursor(15, 210); tft.print("Tap: " + gLastTapInfo); // ===== KARTU TIDAK TERDAFTAR ===== if (!registered || resolved.length() == 0) { // Tampilkan notifikasi dan BERHENTI - tidak mendaftar ke database Serial.println(String("RFID_UID=") + uid + String(" NOT_REGISTERED - REJECTED")); showStatus("KARTU TIDAK\nTERDAFTAR", TFT_RED, uid, 5000); return true; // stop here - no DB registration, no RECOG } bool autoAssigned = jsonGetBool(lookResp, "auto_assigned", false); if (autoAssigned) { // ===== KARTU BARU SAJA OTOMATIS DIDAFTARKAN ===== Serial.println(String("RFID_UID=") + uid + String(" AUTO_ASSIGNED TO=") + resolved); showStatus("KARTU\nTERDAFTAR", TFT_GREEN, resolved, 5000); return true; // stop here - tidak langsung diteruskan ke mode kamera } // ===== KARTU TERDAFTAR (NORMAL RECOG) ===== // Tampilkan nama, lalu set flag agar loop() tampilkan kamera dulu 3 detik sebelum capture Serial.println(String("RFID_UID=") + uid + String(" NAME=") + resolved); gExpectedName = resolved; gExpectedUid = uid; gExpectedSetAt = millis(); // Tampilkan notif: nama + instruksi showStatus(resolved, TFT_GREEN, "Siapkan wajah...", 3500); // Mulai proses pengenalan wajah terus-menerus selama 15 detik gKeepTryingCmd = "RECOG"; gKeepTryingUntil = millis() + 15000; gLastActionSuccess = false; gIsWebJob = false; // Tunda capture pertama selama 3 detik agar pengguna bisa melihat preview wajahnya gNextCaptureTime = millis() + 3000; gCameraActive = true; // tampilkan kamera preview mulai sekarang } return true; } if (cmd == "LIVE") { // live streaming removed return false; } if (cmd == "STOP") { // live streaming removed return false; } gBusy = true; if (cmd.startsWith("ENROLL ") || cmd == "RECOG") { if (gUploadTaskBusy) { Serial.println("[CMD] Upload task is currently busy!"); gBusy = false; return false; } if (cmd.startsWith("ENROLL ")) { String name = cmd.substring(7); name.trim(); if (name.length() == 0) { showStatus("NAMA KOSONG", TFT_RED, "Silakan isi nama"); gBusy = false; return false; } gUploadUrl = String(SERVER_BASE) + "/enroll?name=" + urlEncode(name); if (isDigits(gJobPersonId)) gUploadUrl += String("&person_id=") + gJobPersonId; } else { String uid = gJobRecogUid.length() ? gJobRecogUid : gExpectedUid; String endpoint = uid.length() ? "/rfid/attendance" : "/recognize"; gUploadUrl = String(SERVER_BASE) + endpoint + "?threshold=" + String(gJobRecogThreshold, 2); if (uid.length()) gUploadUrl += "&uid=" + uid; } camera_fb_t *fb = esp_camera_fb_get(); if (!fb) { showStatus("KAMERA ERROR", TFT_RED, "Camera fb null"); gBusy = false; return false; } // Konversi ke JPEG secepat kilat (beberapa ms) if (!frame2jpg(fb, 70, &gUploadBuf, &gUploadLen)) { showStatus("JPEG ENCODE FAIL", TFT_RED, ""); esp_camera_fb_return(fb); gBusy = false; return false; } esp_camera_fb_return(fb); // KEMBALIKAN FRAME SEGERA AGAR LAYAR TIDAK MACET! gUploadCmd = cmd; gUploadFinished = false; gUploadTaskBusy = true; // TRIGGER TASK UPLOAD DI BACKGROUND showStatus("MENGIRIM...", TFT_CYAN, ""); gBusy = false; return true; } gBusy = false; return true; // response handled per command } void drawIPAddress() { String ipText; String wifiText; if (WiFi.status() == WL_CONNECTED) { ipText = WiFi.localIP().toString(); wifiText = WiFi.SSID(); } else { ipText = WiFi.softAPIP().toString(); wifiText = "AP_MODE"; } int barHeight = 18; // background bawah tft.fillRect( 0, tft.height() - barHeight, tft.width(), barHeight, TFT_BLACK); tft.setTextSize(1); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setCursor( 4, tft.height() - 14); tft.print(wifiText); tft.print(" | IP: "); tft.print(ipText); } void setup() { Serial.begin(115200); delay(500); Serial.println("\n\n========== SETUP START =========="); // (RFID UART initialization moved to the end of setup to prevent interrupt storms) if (TFT_BL >= 0) { Serial.println("[SETUP] Enabling TFT backlight"); pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH); } Serial.println("[SETUP] Initializing TFT..."); tft.init(); tft.setRotation(2); tft.setSwapBytes(true); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextSize(2); tft.setCursor(10, 10); tft.println("Init camera..."); Serial.println("[SETUP] TFT ready"); Serial.println("[SETUP] Loading config..."); loadConfig(); Serial.println("[SETUP] Connecting WiFi..."); xTaskCreatePinnedToCore(uploadTask, "UploadTask", 16384, NULL, 1, &UploadTaskHandle, 0); connectWiFi(); if (WiFi.status() != WL_CONNECTED) { Serial.println("[SETUP] WiFi failed, starting AP mode"); startAPMode(); } server.on("/", handleRoot); server.on( "/save", HTTP_POST, handleSave); server.on( "/reset_server", HTTP_POST, handleResetServer); Serial.println("[SETUP] Starting web server..."); server.begin(); Serial.println("WEB SERVER STARTED"); Serial.println("[SETUP] Initializing camera..."); if (!initCamera()) { tft.fillScreen(TFT_RED); tft.setCursor(10, 10); tft.setTextColor(TFT_WHITE, TFT_RED); tft.println("Camera init failed"); Serial.println("\n[FATAL] Camera init failed!"); Serial.println("[DEBUG] Check:"); Serial.println(" 1. Camera ribbon cable connected?"); Serial.println(" 2. RFID UART pins conflict (44/43)?"); Serial.println(" 3. PSRAM detected?"); Serial.printf(" 4. PSRAM: %s\n", psramFound() ? "YES" : "NO"); Serial.println(" 5. Try disabling ENABLE_RFID_UART"); while (true) delay(1000); } // Camera starts OFF - only activates during ENROLL/RECOG gCameraActive = false; Serial.println("[SETUP] Camera OK!"); // Memulai UART RFID di AKHIR setup() agar interupsi serial // tidak mengganggu proses inisialisasi kamera & WiFi yang lambat/sensitif if (rfidUartEnabled()) { Serial.printf("[SETUP] Enabling RFID UART on RX=%d TX=%d\n", RFID_RX_PIN, RFID_TX_PIN); RFIDSerial.begin(RFID_BAUD, SERIAL_8N1, RFID_RX_PIN, RFID_TX_PIN); // Kuras/flush buffer untuk membuang data sampah (garbage) yang mungkin masuk saat booting while(RFIDSerial.available() > 0) { RFIDSerial.read(); } } else { Serial.println("[SETUP] RFID UART disabled or invalid pins"); } showSmallBelowTop("Ready", TFT_GREEN); Serial.println("READY"); Serial.println("========== SETUP DONE ==========\n"); } void loop() { server.handleClient(); unsigned long now = millis(); // Periodic heartbeat so we can confirm Serial output path and device is alive. if (now - gLastHeartbeat >= HEARTBEAT_MS) { gLastHeartbeat = now; Serial.println(String("[HB] ms=") + now + String(" wifi=") + (WiFi.status() == WL_CONNECTED ? "OK" : "DIS") + String(" busy=") + (gBusy ? "1" : "0")); } if (WiFi.status() != WL_CONNECTED && (now - gLastReconnect >= RECONNECT_INTERVAL_MS)) { gLastReconnect = now; Serial.println("[WIFI] Disconnected, trying reconnect..."); WiFi.reconnect(); // Reset server failure counter when reconnecting WiFi gServerFailureCount = 0; gServerDisabled = false; Serial.println("[WIFI] Server connection re-enabled"); } // ===== CONTINUOUS CAPTURE (Auto-Retry) ===== if (gKeepTryingCmd.length() > 0 && !gBusy) { if (now >= gKeepTryingUntil) { // Timeout postJobResult(false); gKeepTryingCmd = ""; gCameraActive = false; showStatus("TIMEOUT", TFT_RED, "Wajah tidak dikenali", 3000); } else { // PROSES HASIL BACKGROUND UPLOAD if (gUploadTaskBusy && gUploadFinished) { String resp = gUploadResult; String cmd = gUploadCmd; gUploadTaskBusy = false; gUploadFinished = false; if (cmd == "RECOG") { if (resp.startsWith("HTTP 400")) { showStatus("COBA LAGI...", TFT_ORANGE, "Wajah tidak jelas", 1000); } else if (resp.startsWith("HTTP ERR") || resp.startsWith("HTTP 4") || resp.startsWith("HTTP 5") || resp.startsWith("WIFI")) { showStatus("ERR: " + resp, TFT_RED, "", 1000); } else { bool hasExpected = gExpectedName.length() > 0 && (millis() - gExpectedSetAt) <= EXPECTED_TTL_MS; if (hasExpected) { String respLower = resp; respLower.toLowerCase(); String expLower = gExpectedName; expLower.toLowerCase(); bool matchFound = respLower.indexOf("\"match\": true") >= 0 || respLower.indexOf("\"match\":true") >= 0; bool nameFound = respLower.indexOf("\"name\":\"" + expLower + "\"") >= 0; if (matchFound && nameFound) { showStatus("ABSEN SUKSES", TFT_GREEN, gExpectedName); gLastActionSuccess = true; } else { showStatus("ABSEN GAGAL", TFT_RED, "Wajah tidak cocok", 1000); } // gExpectedName = ""; gExpectedUid = ""; gExpectedSetAt = 0; // Removed so retries retain context } else { if (resp.indexOf("\"match\": true") >= 0 || resp.indexOf("\"match\":true") >= 0) { showStatus("ABSEN SUKSES", TFT_GREEN, ""); gLastActionSuccess = true; } else { showStatus("SERVER MERESPON", TFT_CYAN, resp, 1000); } } } } else if (cmd.startsWith("ENROLL ")) { if (resp.startsWith("HTTP 400")) { showStatus("COBA LAGI...", TFT_ORANGE, "Wajah tidak jelas", 1000); } else if (resp.startsWith("HTTP ERR") || resp.startsWith("HTTP 4") || resp.startsWith("HTTP 5") || resp.startsWith("WIFI")) { showStatus("ERR: " + resp, TFT_RED, "", 1000); } else { showStatus("ENROLL SUKSES", TFT_GREEN, resp); gLastActionSuccess = true; } } if (gLastActionSuccess) { postJobResult(true); gKeepTryingCmd = ""; // Stop trying gCameraActive = false; // Turn off camera on success showStatus("BERHASIL", TFT_GREEN, "", 3000); } else { // Tunggu 500ms saja sebelum mengambil foto berikutnya (karena upload selesai) gNextCaptureTime = millis() + 500; } } if (now >= gNextCaptureTime && !gUploadTaskBusy) { Serial.println(String("[AUTO] Capture frame untuk ") + gKeepTryingCmd); handleCommand(gKeepTryingCmd); lastJobPoll = millis(); // Reset poll timer // Note: gNextCaptureTime will be set when upload finishes above } } } if (!gBusy && (now - lastPreview >= PREVIEW_INTERVAL_MS)) { if (gCameraActive) { camera_fb_t *fb = esp_camera_fb_get(); if (fb) { drawFrameToTFT(fb); drawIPAddress(); drawOverlayMessage(); esp_camera_fb_return(fb); } // Reset flag since we are showing camera gMenuDrawn = false; } else { // ===== MENU UTAMA ===== if (millis() > gOverlayExpire) { if (!gMenuDrawn) { tft.fillScreen(TFT_BLACK); // Header bar tft.fillRect(0, 0, tft.width(), 30, 0x0010); // dark navy tft.setTextColor(TFT_WHITE, 0x0010); tft.setTextSize(1); tft.setCursor(8, 11); tft.print("ABSENSI WAJAH + RFID"); // Icon area tft.setTextSize(3); tft.setTextColor(0x07FF, TFT_BLACK); // cyan tft.setCursor(30, 50); tft.print("[RFID]"); // Main instruction tft.setTextSize(2); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(15, 110); tft.print("TAP KARTU RFID"); tft.setCursor(15, 135); tft.print("UNTUK ABSEN"); // Divider tft.drawFastHLine(10, 165, tft.width() - 20, 0x4208); // dark gray // Status legend tft.setTextSize(1); tft.setTextColor(0x07E0, TFT_BLACK); // green tft.setCursor(15, 175); tft.print("TERDAFTAR -> Absen Wajah"); tft.setTextColor(0xFD20, TFT_BLACK); // orange tft.setCursor(15, 190); tft.print("BELUM DAFTAR -> Ditolak"); if (gLastTapInfo.length() > 0) { tft.setTextSize(1); tft.setTextColor(0x07FF, TFT_BLACK); // CYAN tft.setCursor(15, 210); tft.print("Tap: " + gLastTapInfo); } drawIPAddress(); gMenuDrawn = true; } } else { // Ada overlay aktif - tetap gambar di background hitam gMenuDrawn = false; drawOverlayMessage(); } } lastPreview = now; } // Live streaming removed to keep preview smooth. if (ENABLE_SERIAL_COMMANDS && Serial.available()) { String cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd.length() > 0) { // Only run explicit commands when enabled. // Note: RFID slave lines are also delivered via Serial, so keep RFID working. handleCommand(cmd); lastJobPoll = millis(); // Reset penanda waktu poll agar dihitung dari waktu selesai perintah } } // Read RFID lines from dedicated UART (ESP8266). // This stays active even when ENABLE_SERIAL_COMMANDS=false. if (rfidUartEnabled()) { while (RFIDSerial.available() > 0) { char c = (char)RFIDSerial.read(); if (c == '\r') continue; if (c == '\n') { String line = gRfidLineBuf; gRfidLineBuf = ""; line.trim(); if (line.length()) { Serial.println(String("[RFID_UART] Received: ") + line); Serial.println(String("[RFID_UART] Camera state: gCameraActive=") + (gCameraActive ? "true" : "false")); handleCommand(line); lastJobPoll = millis(); // Reset penanda waktu poll setelah proses RFID selesai } break; // handle at most 1 line per loop to keep preview smooth } if (gRfidLineBuf.length() < 160) gRfidLineBuf += c; else gRfidLineBuf = ""; // drop overly long / garbage line } } // Web control: poll for a job and execute it. // Hanya jalankan polling jika sistem tidak sedang sibuk. if (ENABLE_JOB_POLL) { if (gBusy) { // Jika sedang sibuk, undur terus penanda waktu agar jeda poll dihitung // setelah status gBusy kembali menjadi false (idle). lastJobPoll = now; } else if (now - lastJobPoll >= JOB_POLL_INTERVAL_MS) { lastJobPoll = now; pollAndRunJob(); // Set ke millis() setelah pollAndRunJob selesai, agar waktu tunggu SSL handshake / job // tidak memakan durasi cooldown polling (cooldown dihitung dari waktu idle penuh). lastJobPoll = millis(); } } delay(10); }