import os import json import tempfile import time import threading from typing import Any import requests from dotenv import load_dotenv current_dir = os.path.dirname(os.path.abspath(__file__)) dotenv_path = os.path.join(current_dir, ".env") load_dotenv(dotenv_path=dotenv_path) import cv2 import numpy as np from deepface import DeepFace from fastapi import FastAPI, HTTPException, Query, Request from starlette.requests import ClientDisconnect from fastapi.responses import HTMLResponse, JSONResponse, Response from face_match import is_recognized_match from db import ( ensure_data_table, get_connection_from_env, load_all_faces, list_attendance_log, list_all_persons, log_attendance, save_or_update_person, upsert_person_rfid, get_person_by_rfid, claim_rfid_by_name, get_or_create_data_row, get_person_by_name, delete_person_by_rfid, delete_person_by_name, delete_unused_persons, delete_old_attendance_logs, ) CASCADE_PATH = "src/face.xml" DEFAULT_MODEL_NAME = os.environ.get("FACE_MODEL", "Facenet") app = FastAPI(title="ESP32 Face Backend", version="2.0") _latest_jpeg: bytes | None = None _latest_ts: float | None = None _recognize_hits: int = 0 _job_seq: int = 0 _pending_jobs: list[dict[str, Any]] = [] _last_result: dict[str, Any] | None = None _armed_enroll: dict[str, Any] | None = None _SUPABASE_URL = os.environ.get("SUPABASE_URL", "").rstrip("/") _SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "") def _push_to_supabase(nama_user: str) -> None: if not _SUPABASE_URL or not _SUPABASE_ANON_KEY: return try: headers = { "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal", } resp = requests.post(f"{_SUPABASE_URL}/rest/v1/output_alat", headers=headers, json={"nama_user": nama_user}, timeout=10) print(f"[SUPABASE] sent '{nama_user}' status={resp.status_code}") except Exception as exc: print(f"[SUPABASE] exception: {exc}") def _push_to_supabase_async(nama_user: str) -> None: threading.Thread(target=_push_to_supabase, args=(nama_user,), daemon=True).start() def _looks_like_rfid_uid(value: str) -> bool: val = value.strip() if not val: return False parts = val.split(":") if len(parts) > 1: if all(len(p) == 2 and all(c in "0123456789ABCDEFabcdef" for c in p) for p in parts): return 4 <= len(parts) <= 16 return False if all(c in "0123456789ABCDEFabcdef" for c in val) and 8 <= len(val) <= 32: return True return False def _enqueue_job(job_type: str, name: str | None = None) -> dict[str, Any]: global _job_seq, _pending_jobs _job_seq += 1 job = {"id": _job_seq, "type": job_type, "name": name, "created_at": time.time()} _pending_jobs.append(job) print(f"[JOB] enqueue id={job['id']} type={job_type} name={name}") return job # ============================================================ # WEB UI HTML/CSS # ============================================================ _CSS = """ :root{--bg:#080c12;--surface:#0e1520;--card:#111b28;--border:#1e2d40;--accent:#3b82f6;--green:#10b981;--red:#ef4444;--yellow:#f59e0b;--text:#e2e8f0;--muted:#64748b} *{box-sizing:border-box;margin:0;padding:0} body{font-family:"Inter",sans-serif;background:var(--bg);color:var(--text);min-height:100vh} a{color:var(--accent);text-decoration:none} .topbar{background:linear-gradient(90deg,#0d1b2e,#0e1f35);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between} .topbar h1{font-size:18px;font-weight:700;background:linear-gradient(90deg,#60a5fa,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent} .topbar .badge{font-size:11px;padding:3px 10px;border-radius:20px;background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#93c5fd} .tabs{display:flex;gap:4px;padding:16px 24px 0;border-bottom:1px solid var(--border);background:var(--surface)} .tab{padding:10px 18px;border-radius:8px 8px 0 0;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);border:1px solid transparent;border-bottom:none;transition:all .2s} .tab:hover{color:var(--text);background:rgba(255,255,255,.04)} .tab.active{background:var(--bg);border-color:var(--border);color:var(--accent)} .content{display:none;padding:24px} .content.active{display:block} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px} .card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px;transition:border-color .2s} .card:hover{border-color:rgba(59,130,246,.4)} .card h3{font-size:14px;font-weight:600;margin-bottom:4px;color:#93c5fd} .card p{font-size:12px;color:var(--muted);margin-bottom:12px;line-height:1.5} label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin:10px 0 4px} input[type=text],input[type=number]{width:100%;padding:9px 12px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);font-size:13px;outline:none;transition:border-color .2s} input:focus{border-color:var(--accent)} .btn{display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s} .btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:#2563eb;transform:translateY(-1px)} .btn-success{background:var(--green);color:#fff}.btn-success:hover{background:#059669} .btn-danger{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3)}.btn-danger:hover{background:rgba(239,68,68,.25)} .btn-warn{background:rgba(245,158,11,.15);color:var(--yellow);border:1px solid rgba(245,158,11,.3)}.btn-warn:hover{background:rgba(245,158,11,.25)} .btn-ghost{background:rgba(255,255,255,.06);color:var(--text);border:1px solid var(--border)}.btn-ghost:hover{background:rgba(255,255,255,.1)} .btn-sm{padding:5px 10px;font-size:12px} pre{white-space:pre-wrap;background:#0b1220;border:1px solid var(--border);padding:10px 12px;border-radius:10px;font-size:12px;margin-top:8px;max-height:160px;overflow-y:auto;color:#94a3b8} .tbl-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--border)} table{width:100%;border-collapse:collapse;font-size:13px} thead{background:#0d1828} th{padding:10px 14px;text-align:left;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);border-bottom:1px solid var(--border)} td{padding:10px 14px;border-bottom:1px solid rgba(30,45,64,.6);vertical-align:middle} tr:last-child td{border-bottom:none} tr:hover td{background:rgba(59,130,246,.04)} .chip{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500} .chip-green{background:rgba(16,185,129,.15);color:#34d399;border:1px solid rgba(16,185,129,.3)} .chip-red{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)} .chip-blue{background:rgba(59,130,246,.15);color:#93c5fd;border:1px solid rgba(59,130,246,.3)} .chip-gray{background:rgba(100,116,139,.15);color:var(--muted);border:1px solid var(--border)} .stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px} .stat{flex:1;min-width:120px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px} .stat .val{font-size:26px;font-weight:700;color:var(--accent)} .stat .lbl{font-size:11px;color:var(--muted);margin-top:2px} .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center} .sep{height:1px;background:var(--border);margin:16px 0} .mt{margin-top:12px} """ def _html_page(title, body): return f""" {title}

🧲 ESP32 Face System

API Docs
🔧 Dashboard
👤 Data Pengguna
📋 Log Absen
🗑 Cleanup
⚙ Config ESP32
{body} """ _DASHBOARD_BODY = """

🔓 Step 1 — Set Identitas

Isi nama atau UID RFID untuk dikunci sebelum capture wajah.

-

📷 Step 2 — Jepret & Daftar Wajah

Setelah identitas di-set, klik untuk capture dan simpan embedding wajah.

-

👁 Deteksi / Absen

Trigger ESP32 untuk ambil foto dan kenali wajah.

-

📈 Status Terakhir ESP32

Hasil terakhir yang dikirim ESP32 setelah eksekusi job.

-
Data Pengguna
Tabel data_fcgntion
IDNamaRFID UIDWajahTerdaftarAksi
Loading...
Log Absensi
Riwayat tap kartu + hasil rekognisi
WaktuRFID UIDNama DikenaliWajah OK
Loading...
Cleanup Data
Hapus data tidak terpakai atau sudah lama

🗑 Hapus Data Kosong

Hapus baris yang tidak punya wajah (fc=NULL) dan tidak punya RFID (rfid_uid=NULL). Baris ini tidak berfungsi untuk system.

-

📅 Hapus Log Lama

Hapus entri attendance_log yang lebih lama dari N hari.

-

🚫 Hapus Pengguna

Hapus pengguna tertentu dari data_fcgntion.

-
Config ESP32
Atur koneksi WiFi dan Server untuk modul ESP32

⚙ ESP32-S3 CAMERA CONFIG

Pastikan laptop/PC terhubung ke WiFi ESP32 (AP Mode) atau masukkan IP ESP32 jika sudah terhubung ke jaringan.

↻ Reset Server Connection

Kirim perintah ke ESP32 untuk mereset status koneksi server jika ESP32 berhenti merespon (API FAIL).

-
""" @app.get("/", response_class=HTMLResponse) def dashboard(): return _html_page("ESP32 Face System", _DASHBOARD_BODY) @app.get("/debug/recognize_hits") def debug_recognize_hits(): return {"ok": True, "hits": _recognize_hits} @app.get("/job/queue") def job_queue(): return {"ok": True, "pending": _pending_jobs, "count": len(_pending_jobs)} @app.post("/arm/enroll") def arm_enroll( name: str = Query(..., min_length=1, max_length=255), uid: str | None = Query(None, min_length=1, max_length=64), ): """Set identity for the next capture-enroll.""" global _armed_enroll val = name.strip() uid_norm = uid.strip().upper() if uid else None is_uid = _looks_like_rfid_uid(val) parsed_uid = uid_norm or (val.upper() if is_uid else None) parsed_name = None if (is_uid and parsed_uid == val.upper() and not uid_norm) else val row_id = None conn = get_connection_from_env() ensure_data_table(conn) try: if parsed_name: row_id = get_or_create_data_row(conn, parsed_name) if parsed_uid: upsert_person_rfid(conn, parsed_uid, parsed_name) except Exception: pass finally: conn.close() _armed_enroll = { "uid": parsed_uid, "name": parsed_name if parsed_name else parsed_uid, "row_id": row_id, "armed_at": time.time(), } return {"ok": True, "armed": _armed_enroll} @app.delete("/arm/enroll") def clear_arm_enroll(): global _armed_enroll _armed_enroll = None return {"ok": True} @app.get("/arm/enroll") def get_arm_enroll(): if not _armed_enroll: return {"ok": True, "armed": False, "name": None} return {"ok": True, "armed": True, "name": _armed_enroll.get("name"), "armed_at": _armed_enroll.get("armed_at")} @app.post("/job/enroll") def job_enroll(name: str = Query(..., min_length=1, max_length=255)): job = _enqueue_job("ENROLL", name=name.strip()) return {"ok": True, "job": job} @app.post("/job/capture_enroll") def job_capture_enroll(): if not _armed_enroll or not _armed_enroll.get("name"): raise HTTPException(status_code=409, detail="No armed enroll identity. Call /arm/enroll first") job = _enqueue_job("ENROLL", name=str(_armed_enroll["name"])) if _armed_enroll.get("uid"): job["uid"] = str(_armed_enroll["uid"]) if _armed_enroll.get("row_id"): job["row_id"] = int(_armed_enroll["row_id"]) job["armed"] = True return {"ok": True, "job": job, "armed": _armed_enroll} @app.post("/job/recog") def job_recog( threshold: float = Query(0.25, ge=0.0, le=1.0), uid: str | None = Query(None, min_length=1, max_length=64), ): job = _enqueue_job("RECOG", name=None) job["threshold"] = float(threshold) if uid and uid.strip(): job["uid"] = uid.strip().upper() return {"ok": True, "job": job} @app.get("/job/next") def job_next(device: str | None = Query(None)): global _pending_jobs if not _pending_jobs: return {"ok": True, "job": None} job = _pending_jobs.pop(0) job["taken_at"] = time.time() if device: job["device"] = device print(f"[JOB] device={device} take id={job.get('id')} type={job.get('type')}") return {"ok": True, "job": job} @app.post("/job/result") async def job_result(request: Request): global _last_result try: data = await request.json() except Exception: raise HTTPException(status_code=400, detail="Invalid JSON") data["received_at"] = time.time() _last_result = data print(f"[JOB] result from={data.get('from')} type={data.get('type')} name={data.get('name')}") return {"ok": True} @app.get("/job/last") def job_last(): return {"ok": True, "result": _last_result} # ============================================================ # FACE HELPERS # ============================================================ def _parse_embedding(obj) -> np.ndarray: if obj is None: raise ValueError("embedding is None") emb = obj if isinstance(emb, dict) and "embedding" in emb: emb = emb["embedding"] if isinstance(emb, list) and len(emb) > 0 and isinstance(emb[0], dict) and "embedding" in emb[0]: emb = emb[0]["embedding"] if isinstance(emb, dict) and "embedding" in emb: emb = emb["embedding"] arr = np.array(emb).astype(float).reshape(-1) if arr.size == 0: raise ValueError("empty embedding") return arr def _cosine_distance(a: np.ndarray, b: np.ndarray) -> float: a_n = a / (np.linalg.norm(a) + 1e-10) b_n = b / (np.linalg.norm(b) + 1e-10) return 1.0 - float(np.dot(a_n, b_n)) def _crop_first_face_bgr(img_bgr: np.ndarray): gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) face_cascade = cv2.CascadeClassifier(CASCADE_PATH) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60)) if len(faces) == 0: return None x, y, w, h = faces[0] crop = img_bgr[y:y+h, x:x+w] pad = int(0.25 * max(w, h)) padded = cv2.copyMakeBorder(crop, pad, pad, pad, pad, borderType=cv2.BORDER_CONSTANT, value=[0,0,0]) return cv2.resize(padded, (224, 224), interpolation=cv2.INTER_AREA) def _represent_from_bgr_face(face_bgr_224: np.ndarray) -> np.ndarray: ts = int(time.time() * 1000) tmp = os.path.join(tempfile.gettempdir(), f"esp32_face_{ts}.jpg") cv2.imwrite(tmp, face_bgr_224) try: try: rep = DeepFace.represent(tmp, model_name=DEFAULT_MODEL_NAME, enforce_detection=True) except Exception: rep = DeepFace.represent(tmp, model_name=DEFAULT_MODEL_NAME, enforce_detection=False, detector_backend="opencv") return _parse_embedding(rep) finally: try: os.remove(tmp) except Exception: pass def _load_gallery_from_db() -> dict: """Load all face embeddings from data_fcgntion into memory.""" conn = get_connection_from_env() ensure_data_table(conn) rows = load_all_faces(conn) conn.close() gallery = {} for row in rows: name = row.get("name") emb_raw = row.get("embedding") if isinstance(emb_raw, str): try: emb_raw = json.loads(emb_raw) except Exception: pass try: gallery[name] = _parse_embedding(emb_raw) except Exception: continue return gallery def _build_recognition_response(best_name, best_score, threshold, uid=None, second_score=None) -> dict: match = is_recognized_match(best_score, threshold, second_score=second_score) recognized_name = best_name if match else "Unknown" result = { "ok": True, "name": recognized_name, "score": float(best_score) if best_score is not None else None, "threshold": float(threshold), "match": bool(match), "best": best_name, } if uid and uid.strip() and match: uid_norm = uid.strip().upper() result["uid"] = uid_norm conn = get_connection_from_env() ensure_data_table(conn) try: log_attendance(conn, uid=uid_norm, recognized_name=recognized_name, face_ok=True) finally: conn.close() if match and best_name: print(f"[SUPABASE] Wajah dikenali: '{best_name}' - push ke Supabase...") _push_to_supabase_async(best_name) return result def _recognize_embedding(q: np.ndarray, threshold: float, uid=None) -> dict: gallery = _load_gallery_from_db() if not gallery: uid_norm = uid.strip().upper() if uid and uid.strip() else None resp = {"ok": True, "name": "Unknown", "score": None, "reason": "empty_gallery", "threshold": float(threshold)} if uid_norm: resp["uid"] = uid_norm conn = get_connection_from_env() ensure_data_table(conn) try: log_attendance(conn, uid=uid_norm, recognized_name="Unknown", face_ok=False) finally: conn.close() return resp best_name = None best_score = float("inf") second_best_score = float("inf") for gname, gvec in gallery.items(): d = _cosine_distance(q, gvec) if d < best_score: second_best_score = best_score best_score = d best_name = gname elif d < second_best_score: second_best_score = d return _build_recognition_response(best_name, best_score, threshold, uid, second_score=second_best_score) # ============================================================ # RFID ENDPOINTS # ============================================================ @app.post("/rfid/register") def rfid_register( request: Request, uid: str = Query(..., min_length=1, max_length=64), name: str | None = Query(None, min_length=1, max_length=128), ): """Register/upsert RFID UID in data_fcgntion.""" try: print(f"[RFID_REGISTER] url={request.url} query={request.url.query}") except Exception: pass uid_norm = uid.strip().upper() if not uid_norm: raise HTTPException(status_code=422, detail="uid empty") raw_name = name.strip() if name else "" conn = get_connection_from_env() ensure_data_table(conn) try: claimed = False if raw_name: claimed = claim_rfid_by_name(conn, raw_name, uid_norm) if not claimed: upsert_person_rfid(conn, uid_norm, raw_name) elif _armed_enroll and _armed_enroll.get("name"): armed_name = _armed_enroll["name"] claimed = claim_rfid_by_name(conn, armed_name, uid_norm) if claimed and _armed_enroll: _armed_enroll["uid"] = uid_norm else: upsert_person_rfid(conn, uid_norm, armed_name) else: upsert_person_rfid(conn, uid_norm, None) row = get_person_by_rfid(conn, uid_norm) finally: conn.close() return {"ok": True, "uid": uid_norm, "name": raw_name or uid_norm, "row": row} @app.get("/rfid/lookup") def rfid_lookup( request: Request, uid: str = Query(..., min_length=1, max_length=64), debug: int = Query(0, ge=0, le=1), ): """Lookup RFID UID -> person name from data_fcgntion.""" uid_norm = uid.strip().upper() if not uid_norm: raise HTTPException(status_code=422, detail="uid empty") try: print(f"[RFID_LOOKUP] url={request.url} query={request.url.query}") except Exception: pass conn = get_connection_from_env() ensure_data_table(conn) row = get_person_by_rfid(conn, uid_norm) if not row: # Cek apakah ada person yang BELUM memiliki RFID cursor = conn.cursor() cursor.execute("SELECT id_tabel, person, fc FROM data_fcgntion WHERE rfid_uid IS NULL OR rfid_uid = '' ORDER BY create_at ASC LIMIT 1") unassigned_row = cursor.fetchone() if unassigned_row: person_id = int(unassigned_row[0]) person_name = unassigned_row[1] has_face = unassigned_row[2] is not None cursor.execute("UPDATE data_fcgntion SET rfid_uid=%s WHERE id_tabel=%s", (uid_norm, person_id)) conn.commit() conn.close() resp = { "ok": True, "uid": uid_norm, "registered": True, "name": person_name, "has_face": has_face, "auto_assigned": True } print(f"[RFID_LOOKUP] auto-assigned result={resp}") return resp conn.close() if not row: resp = {"ok": True, "uid": uid_norm, "registered": False, "name": None} print(f"[RFID_LOOKUP] result={resp}") return resp resp = { "ok": True, "uid": row.get("rfid_uid"), "registered": True, "name": row.get("person"), "has_face": row.get("has_face", False), } if debug == 1: resp["create_at"] = str(row.get("create_at")) print(f"[RFID_LOOKUP] result={resp}") return resp # ============================================================ # FACE ENROLLMENT & RECOGNITION ENDPOINTS # ============================================================ @app.post("/enroll") async def enroll( request: Request, name: str = Query(..., min_length=1), uid: str | None = Query(None), ): """ESP32 sends raw JPEG bytes. Extract face, compute embedding, save to data_fcgntion.""" data = await request.body() if not data: raise HTTPException(status_code=422, detail="Missing image body") img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise HTTPException(status_code=400, detail="Invalid image") face = _crop_first_face_bgr(img) if face is None: raise HTTPException(status_code=400, detail="No face detected") vec = _represent_from_bgr_face(face) uid_norm = uid.strip().upper() if uid else None conn = get_connection_from_env() ensure_data_table(conn) row_id = save_or_update_person(conn, name, json.dumps(vec.tolist()), rfid_uid=uid_norm) conn.close() return {"ok": True, "name": name, "dim": int(vec.size), "row_id": row_id} @app.post("/recognize") async def recognize( request: Request, threshold: float = Query(0.25, ge=0.0, le=1.0), uid: str | None = Query(None, min_length=1, max_length=64), ): global _recognize_hits _recognize_hits += 1 print(f"[RECOGNIZE] uid={uid} threshold={threshold}") try: data = await request.body() except ClientDisconnect: print("[RECOGNIZE] client disconnected before body received") raise HTTPException(status_code=400, detail="Client disconnected") if not data: raise HTTPException(status_code=422, detail="Missing image body") img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise HTTPException(status_code=400, detail="Invalid image") face = _crop_first_face_bgr(img) if face is None: raise HTTPException(status_code=400, detail="No face detected") q = _represent_from_bgr_face(face) return _recognize_embedding(q, float(threshold), uid) @app.post("/rfid/attendance") async def rfid_attendance( request: Request, uid: str = Query(..., min_length=1, max_length=64), threshold: float = Query(0.25, ge=0.0, le=1.0), ): print(f"[RFID_ATTENDANCE] uid={uid} threshold={threshold}") try: data = await request.body() except ClientDisconnect: print(f"[RFID_ATTENDANCE] client disconnected before body received (uid={uid})") raise HTTPException(status_code=400, detail="Client disconnected") if not data: raise HTTPException(status_code=422, detail="Missing image body") img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise HTTPException(status_code=400, detail="Invalid image") face = _crop_first_face_bgr(img) if face is None: raise HTTPException(status_code=400, detail="No face detected") q = _represent_from_bgr_face(face) uid_norm = uid.strip().upper() gallery = _load_gallery_from_db() if not gallery: return {"ok": True, "match": False, "name": "Unknown", "reason": "empty_gallery"} conn = get_connection_from_env() ensure_data_table(conn) row = get_person_by_rfid(conn, uid_norm) conn.close() if not row or not row.get("person"): return {"ok": True, "match": False, "name": "Unknown", "reason": "uid_not_found"} target_name = row.get("person") if target_name not in gallery: return {"ok": True, "match": False, "name": target_name, "reason": "target_not_loaded"} target_vec = gallery[target_name] target_distance = _cosine_distance(q, target_vec) best_name = None best_score = float("inf") second_best_score = float("inf") for gname, gvec in gallery.items(): d = _cosine_distance(q, gvec) if d < best_score: second_best_score = best_score best_score = d best_name = gname elif d < second_best_score: second_best_score = d match = (best_name == target_name and is_recognized_match(target_distance, threshold, second_score=second_best_score, min_confidence=0.35, margin=0.05)) result = { "ok": True, "name": target_name if match else "Unknown", "score": float(target_distance), "threshold": float(threshold), "match": bool(match), "best": best_name if best_name is not None else "Unknown", "uid": uid_norm } # Hanya catat ke database JIKA COCOK. Ini mencegah spam DB saat ESP32 melakukan auto-retry! if match: conn = get_connection_from_env() try: log_attendance(conn, uid=uid_norm, recognized_name=target_name, face_ok=True) finally: conn.close() print(f"[SUPABASE] Wajah dikenali: '{target_name}' - push ke Supabase...") _push_to_supabase_async(target_name) print(f'[RFID_ATTENDANCE] result={result}') return result # ============================================================ # PERSON / CLEANUP ENDPOINTS # ============================================================ @app.delete("/person/delete") def person_delete( name: str | None = Query(None), uid: str | None = Query(None), ): """Delete a person from data_fcgntion by name or rfid_uid.""" if not name and not uid: raise HTTPException(status_code=422, detail="Provide name or uid") conn = get_connection_from_env() ensure_data_table(conn) try: if uid: delete_person_by_rfid(conn, uid.strip().upper()) return {"ok": True, "deleted_by": "uid", "uid": uid.strip().upper()} else: delete_person_by_name(conn, name.strip()) return {"ok": True, "deleted_by": "name", "name": name.strip()} finally: conn.close() @app.post("/cleanup/unused") def cleanup_unused(): """Delete data_fcgntion rows where fc IS NULL AND rfid_uid IS NULL.""" conn = get_connection_from_env() ensure_data_table(conn) try: count = delete_unused_persons(conn) return {"ok": True, "deleted": count, "msg": f"Dihapus {count} baris data kosong"} finally: conn.close() @app.post("/cleanup/old_logs") def cleanup_old_logs(days: int = Query(30, ge=1, le=3650)): """Delete attendance_log entries older than `days` days.""" conn = get_connection_from_env() ensure_data_table(conn) try: count = delete_old_attendance_logs(conn, days=days) return {"ok": True, "deleted": count, "msg": f"Dihapus {count} log absen > {days} hari"} finally: conn.close() # ============================================================ # API DATA ENDPOINTS # ============================================================ @app.get("/api/persons") def api_list_persons(limit: int = Query(200, ge=1, le=1000)): conn = get_connection_from_env() ensure_data_table(conn) items = list_all_persons(conn, limit=limit) conn.close() for item in items: if item.get("create_at") and not isinstance(item["create_at"], str): item["create_at"] = str(item["create_at"]) return {"ok": True, "items": items} @app.get("/api/attendance") def api_list_attendance(limit: int = Query(200, ge=1, le=1000)): conn = get_connection_from_env() ensure_data_table(conn) items = list_attendance_log(conn, limit=limit) conn.close() for item in items: if item.get("created_at") and not isinstance(item["created_at"], str): item["created_at"] = str(item["created_at"]) return {"ok": True, "items": items} # ============================================================ # FRAME / LIVE PREVIEW # ============================================================ @app.get("/health") def health(): return {"ok": True} @app.get("/live", response_class=HTMLResponse) def live_page(): return """ ESP32 Live

ESP32 Live Preview


latest frame

ESP32 POST JPEG ke /frame.

""" @app.post("/frame") async def ingest_frame(request: Request): global _latest_jpeg, _latest_ts data = await request.body() if not data: raise HTTPException(status_code=422, detail="Missing image body") if len(data) < 3 or not (data[0] == 0xFF and data[1] == 0xD8 and data[2] == 0xFF): raise HTTPException(status_code=400, detail="Not a JPEG") _latest_jpeg = data _latest_ts = time.time() return {"ok": True} @app.get("/latest.jpg") def latest_jpg(annotate: int = Query(0, ge=0, le=1)): if _latest_jpeg is None: raise HTTPException(status_code=404, detail="No frame yet") if annotate != 1: return Response(content=_latest_jpeg, media_type="image/jpeg") img = cv2.imdecode(np.frombuffer(_latest_jpeg, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: return Response(content=_latest_jpeg, media_type="image/jpeg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) face_cascade = cv2.CascadeClassifier(CASCADE_PATH) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60)) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2) ret, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) if not ret: return Response(content=_latest_jpeg, media_type="image/jpeg") return Response(content=buf.tobytes(), media_type="image/jpeg") if __name__ == "__main__": host = os.environ.get("HOST", "0.0.0.0") port = int(os.environ.get("PORT", "8000")) import uvicorn ngrok_url = os.environ.get("NGROK_URL", "").strip() if ngrok_url: print("\n" + "="*70) print("NGROK TUNNEL ACTIVE") print("="*70) print(f"Public URL: {ngrok_url}") print(f"Local URL: http://{host}:{port}") print("="*70 + "\n") uvicorn.run(app, host=host, port=port, reload=False)