TKK_E32232028/esp32_serial_backend/esp32_serial_backend.ino

1598 lines
45 KiB
C++

#include <Arduino.h>
#include <FS.h>
#include <WebServer.h>
#include "esp_camera.h"
#include "img_converters.h"
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#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 <WebServer.h>
#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(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32S3 CAMERA</title>
<style>
body { background:#111; color:white; font-family:Arial; padding:20px; text-align:center; }
.card { background:#222; padding:20px; border-radius:20px; margin-top:20px; }
</style>
</head>
<body>
<div class="card">
<h2>ESP32 CONFIG</h2>
<p>Form konfigurasi telah dipindah ke Web UI Utama.</p>
<p>Silakan buka tab <b>"Config ESP32"</b> di Web UI Utama dan masukkan IP ESP32 ini untuk merubah pengaturan.</p>
</div>
</body>
</html>
)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<uint16_t>((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<uint16_t>(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);
}