1150 lines
46 KiB
Python
1150 lines
46 KiB
Python
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"""<!doctype html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>{title}</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<style>{_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<div class="topbar">
|
|
<h1>🧲 ESP32 Face System</h1>
|
|
<a class="badge" href="/docs">API Docs</a>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="switchTab('dashboard')">🔧 Dashboard</div>
|
|
<div class="tab" onclick="switchTab('persons')">👤 Data Pengguna</div>
|
|
<div class="tab" onclick="switchTab('attendance')">📋 Log Absen</div>
|
|
<div class="tab" onclick="switchTab('cleanup')">🗑 Cleanup</div>
|
|
<div class="tab" onclick="switchTab('setting')">⚙ Config ESP32</div>
|
|
</div>
|
|
{body}
|
|
<script>
|
|
function switchTab(name){{
|
|
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
|
document.querySelectorAll('.content').forEach(c=>c.classList.remove('active'));
|
|
const idx=['dashboard','persons','attendance','cleanup','setting'].indexOf(name);
|
|
document.querySelectorAll('.tab')[idx].classList.add('active');
|
|
document.getElementById('tab_'+name).classList.add('active');
|
|
if(name==='persons') loadPersons();
|
|
if(name==='attendance') loadAttendance();
|
|
}}
|
|
async function j(r){{const t=await r.text();try{{return JSON.stringify(JSON.parse(t),null,2);}}catch(e){{return t;}}}}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
_DASHBOARD_BODY = """
|
|
<div id="tab_dashboard" class="content active">
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>🔓 Step 1 — Set Identitas</h3>
|
|
<p>Isi nama atau UID RFID untuk dikunci sebelum capture wajah.</p>
|
|
<form id="armForm">
|
|
<label>Nama / UID RFID</label>
|
|
<input name="name" type="text" placeholder="Andi atau 04:AB:12:CD" required/>
|
|
<div class="row mt">
|
|
<button class="btn btn-primary" type="submit">🔓 SET</button>
|
|
<button class="btn btn-ghost" id="btnClear" type="button">✕ CLEAR</button>
|
|
</div>
|
|
</form>
|
|
<pre id="armOut">-</pre>
|
|
</div>
|
|
<div class="card">
|
|
<h3>📷 Step 2 — Jepret & Daftar Wajah</h3>
|
|
<p>Setelah identitas di-set, klik untuk capture dan simpan embedding wajah.</p>
|
|
<div class="mt">
|
|
<button class="btn btn-success" id="btnCaptureEnroll">📷 JEPRET & DAFTAR</button>
|
|
</div>
|
|
<pre id="enrollOut">-</pre>
|
|
</div>
|
|
<div class="card">
|
|
<h3>👁 Deteksi / Absen</h3>
|
|
<p>Trigger ESP32 untuk ambil foto dan kenali wajah.</p>
|
|
<button class="btn btn-primary" id="btnRecogNow" style="margin-bottom:10px">⚡ DETEK SEKARANG</button>
|
|
<form id="btnRecog">
|
|
<label>Threshold (default 0.25)</label>
|
|
<input name="threshold" type="number" step="0.01" value="0.25" min="0" max="1"/>
|
|
<label>UID RFID (optional)</label>
|
|
<input name="uid" type="text" placeholder="04:AB:12:CD"/>
|
|
<div class="mt"><button class="btn btn-ghost" type="submit">👁 DETEK</button></div>
|
|
</form>
|
|
<pre id="recogOut">-</pre>
|
|
</div>
|
|
<div class="card">
|
|
<h3>📈 Status Terakhir ESP32</h3>
|
|
<p>Hasil terakhir yang dikirim ESP32 setelah eksekusi job.</p>
|
|
<pre id="lastOut">-</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab_persons" class="content">
|
|
<div class="row" style="margin-bottom:16px;justify-content:space-between">
|
|
<div>
|
|
<div style="font-size:20px;font-weight:700">Data Pengguna</div>
|
|
<div style="font-size:13px;color:var(--muted);margin-top:2px">Tabel data_fcgntion</div>
|
|
</div>
|
|
<button class="btn btn-ghost" onclick="loadPersons()">↻ Refresh</button>
|
|
</div>
|
|
<div id="personsStats" class="stats"></div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Nama</th><th>RFID UID</th><th>Wajah</th><th>Terdaftar</th><th>Aksi</th></tr></thead>
|
|
<tbody id="personsTbl"><tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab_attendance" class="content">
|
|
<div class="row" style="margin-bottom:16px;justify-content:space-between">
|
|
<div>
|
|
<div style="font-size:20px;font-weight:700">Log Absensi</div>
|
|
<div style="font-size:13px;color:var(--muted);margin-top:2px">Riwayat tap kartu + hasil rekognisi</div>
|
|
</div>
|
|
<button class="btn btn-ghost" onclick="loadAttendance()">↻ Refresh</button>
|
|
</div>
|
|
<div id="attendStats" class="stats"></div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr><th>Waktu</th><th>RFID UID</th><th>Nama Dikenali</th><th>Wajah OK</th></tr></thead>
|
|
<tbody id="attendTbl"><tr><td colspan="4" style="text-align:center;color:var(--muted);padding:24px">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab_cleanup" class="content">
|
|
<div style="font-size:20px;font-weight:700;margin-bottom:4px">Cleanup Data</div>
|
|
<div style="font-size:13px;color:var(--muted);margin-bottom:20px">Hapus data tidak terpakai atau sudah lama</div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>🗑 Hapus Data Kosong</h3>
|
|
<p>Hapus baris yang tidak punya wajah (fc=NULL) dan tidak punya RFID (rfid_uid=NULL). Baris ini tidak berfungsi untuk system.</p>
|
|
<button class="btn btn-warn mt" onclick="cleanupUnused()">🗑 Hapus Data Kosong</button>
|
|
<pre id="cleanupUnusedOut">-</pre>
|
|
</div>
|
|
<div class="card">
|
|
<h3>📅 Hapus Log Lama</h3>
|
|
<p>Hapus entri attendance_log yang lebih lama dari N hari.</p>
|
|
<label>Hapus log lebih dari (hari)</label>
|
|
<input id="cleanDays" type="number" value="30" min="1" max="3650"/>
|
|
<button class="btn btn-warn mt" onclick="cleanupLogs()">📅 Hapus Log Lama</button>
|
|
<pre id="cleanupLogsOut">-</pre>
|
|
</div>
|
|
<div class="card">
|
|
<h3>🚫 Hapus Pengguna</h3>
|
|
<p>Hapus pengguna tertentu dari data_fcgntion.</p>
|
|
<label>Nama Pengguna</label>
|
|
<input id="delName" type="text" placeholder="Andi"/>
|
|
<button class="btn btn-danger mt" onclick="deleteByName()">🚫 Hapus by Nama</button>
|
|
<div class="sep"></div>
|
|
<label>RFID UID</label>
|
|
<input id="delUid" type="text" placeholder="04:AB:12:CD"/>
|
|
<button class="btn btn-danger mt" onclick="deleteByUid()">🚫 Hapus by RFID</button>
|
|
<pre id="deleteOut">-</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab_setting" class="content">
|
|
<div style="font-size:20px;font-weight:700;margin-bottom:4px">Config ESP32</div>
|
|
<div style="font-size:13px;color:var(--muted);margin-bottom:20px">Atur koneksi WiFi dan Server untuk modul ESP32</div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>⚙ ESP32-S3 CAMERA CONFIG</h3>
|
|
<p>Pastikan laptop/PC terhubung ke WiFi ESP32 (AP Mode) atau masukkan IP ESP32 jika sudah terhubung ke jaringan.</p>
|
|
<form id="configForm" action="http://192.168.4.1/save" method="POST">
|
|
<label>IP ESP32 (Default: 192.168.4.1 di mode AP)</label>
|
|
<input id="esp_ip" type="text" value="192.168.4.1" onchange="updateEspAction()">
|
|
|
|
<label>WiFi SSID</label>
|
|
<input name="ssid" type="text" placeholder="WiFi SSID" required>
|
|
|
|
<label>Password</label>
|
|
<input name="password" type="password" placeholder="Password">
|
|
|
|
<label>Server URL</label>
|
|
<input name="url" type="text" placeholder="http://192.168.100.16:8000" value="http://192.168.100.16:8000" required>
|
|
|
|
<button type="submit" class="btn btn-primary mt" style="width:100%">💾 SAVE CONFIG</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>↻ Reset Server Connection</h3>
|
|
<p>Kirim perintah ke ESP32 untuk mereset status koneksi server jika ESP32 berhenti merespon (API FAIL).</p>
|
|
<button class="btn btn-danger mt" onclick="resetEspServer()" style="width:100%">↻ RESET SERVER CONNECTION</button>
|
|
<pre id="espConfigOut">-</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function updateEspAction() {
|
|
const ip = document.getElementById('esp_ip').value || '192.168.4.1';
|
|
document.getElementById('configForm').action = `http://${ip}/save`;
|
|
}
|
|
async function resetEspServer() {
|
|
const ip = document.getElementById('esp_ip').value || '192.168.4.1';
|
|
document.getElementById('espConfigOut').textContent = 'Resetting...';
|
|
try {
|
|
const r = await fetch(`http://${ip}/reset_server`, { method: 'POST', mode: 'no-cors' });
|
|
document.getElementById('espConfigOut').textContent = 'Reset command sent! (Check ESP32)';
|
|
} catch(e) {
|
|
document.getElementById('espConfigOut').textContent = 'Error: ' + e;
|
|
}
|
|
}
|
|
|
|
async function refreshLast(){
|
|
try{const r=await fetch('/job/last');document.getElementById('lastOut').textContent=await j(r);}catch(e){}
|
|
}
|
|
setInterval(refreshLast,1500); refreshLast();
|
|
|
|
document.getElementById('armForm').addEventListener('submit',async(e)=>{
|
|
e.preventDefault();
|
|
const fd=new FormData(e.target);
|
|
document.getElementById('armOut').textContent='Saving...';
|
|
const r=await fetch('/arm/enroll?name='+encodeURIComponent(fd.get('name')),{method:'POST'});
|
|
document.getElementById('armOut').textContent=await j(r);
|
|
});
|
|
document.getElementById('btnClear').addEventListener('click',async()=>{
|
|
const r=await fetch('/arm/enroll',{method:'DELETE'});
|
|
document.getElementById('armOut').textContent=await j(r);
|
|
});
|
|
document.getElementById('btnCaptureEnroll').addEventListener('click',async()=>{
|
|
document.getElementById('enrollOut').textContent='Queueing...';
|
|
const r=await fetch('/job/capture_enroll',{method:'POST'});
|
|
document.getElementById('enrollOut').textContent=await j(r);
|
|
});
|
|
document.getElementById('btnRecog').addEventListener('submit',async(e)=>{
|
|
e.preventDefault();
|
|
const fd=new FormData(e.target);
|
|
const thr=fd.get('threshold');
|
|
const uid=(fd.get('uid')||'').trim();
|
|
document.getElementById('recogOut').textContent='Queueing...';
|
|
let url='/job/recog?threshold='+encodeURIComponent(thr);
|
|
if(uid) url+='&uid='+encodeURIComponent(uid);
|
|
const r=await fetch(url,{method:'POST'});
|
|
document.getElementById('recogOut').textContent=await j(r);
|
|
});
|
|
document.getElementById('btnRecogNow').addEventListener('click',async()=>{
|
|
document.getElementById('recogOut').textContent='Queueing...';
|
|
const r=await fetch('/job/recog?threshold=0.25',{method:'POST'});
|
|
document.getElementById('recogOut').textContent=await j(r);
|
|
});
|
|
|
|
async function loadPersons(){
|
|
document.getElementById('personsTbl').innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">Loading...</td></tr>';
|
|
const data=(await (await fetch('/api/persons')).json());
|
|
const items=data.items||[];
|
|
const wf=items.filter(i=>i.has_face).length;
|
|
const wr=items.filter(i=>i.rfid_uid).length;
|
|
document.getElementById('personsStats').innerHTML=
|
|
`<div class="stat"><div class="val">${items.length}</div><div class="lbl">Total</div></div>
|
|
<div class="stat"><div class="val" style="color:var(--green)">${wf}</div><div class="lbl">Punya Wajah</div></div>
|
|
<div class="stat"><div class="val" style="color:var(--accent)">${wr}</div><div class="lbl">Punya RFID</div></div>
|
|
<div class="stat"><div class="val" style="color:var(--yellow)">${items.length-wf}</div><div class="lbl">Belum Enroll</div></div>`;
|
|
if(!items.length){
|
|
document.getElementById('personsTbl').innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">Tidak ada data</td></tr>';
|
|
return;
|
|
}
|
|
document.getElementById('personsTbl').innerHTML=items.map(i=>`
|
|
<tr>
|
|
<td><span class="chip chip-gray">#${i.id}</span></td>
|
|
<td><strong>${i.person||'-'}</strong></td>
|
|
<td>${i.rfid_uid?`<span class="chip chip-blue">${i.rfid_uid}</span>`:'<span class="chip chip-gray">-</span>'}</td>
|
|
<td>${i.has_face?'<span class="chip chip-green">✓ Ada</span>':'<span class="chip chip-red">✗ Belum</span>'}</td>
|
|
<td style="font-size:12px;color:var(--muted)">${i.create_at||'-'}</td>
|
|
<td><button class="btn btn-danger btn-sm" onclick="deletePerson('${(i.person||'').replace(/'/g,"\\'")}','${(i.rfid_uid||'').replace(/'/g,"\\'")}')">Hapus</button></td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
async function deletePerson(name,uid){
|
|
if(!confirm('Hapus pengguna "'+name+'"?')) return;
|
|
let r;
|
|
if(uid) r=await fetch('/person/delete?uid='+encodeURIComponent(uid),{method:'DELETE'});
|
|
else r=await fetch('/person/delete?name='+encodeURIComponent(name),{method:'DELETE'});
|
|
const d=await r.json();
|
|
alert(d.ok?'Terhapus!':('Error: '+JSON.stringify(d)));
|
|
loadPersons();
|
|
}
|
|
|
|
async function loadAttendance(){
|
|
document.getElementById('attendTbl').innerHTML='<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:24px">Loading...</td></tr>';
|
|
const data=(await (await fetch('/api/attendance')).json());
|
|
const items=data.items||[];
|
|
const ok=items.filter(i=>i.face_ok).length;
|
|
document.getElementById('attendStats').innerHTML=
|
|
`<div class="stat"><div class="val">${items.length}</div><div class="lbl">Total Absen</div></div>
|
|
<div class="stat"><div class="val" style="color:var(--green)">${ok}</div><div class="lbl">Wajah Cocok</div></div>
|
|
<div class="stat"><div class="val" style="color:var(--red)">${items.length-ok}</div><div class="lbl">Tidak Cocok</div></div>`;
|
|
if(!items.length){
|
|
document.getElementById('attendTbl').innerHTML='<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:24px">Tidak ada log</td></tr>';
|
|
return;
|
|
}
|
|
document.getElementById('attendTbl').innerHTML=items.map(i=>`
|
|
<tr>
|
|
<td style="font-size:12px;color:var(--muted)">${i.created_at||'-'}</td>
|
|
<td><span class="chip chip-blue">${i.uid||'-'}</span></td>
|
|
<td><strong>${i.recognized_name||'Unknown'}</strong></td>
|
|
<td>${i.face_ok?'<span class="chip chip-green">✓ OK</span>':'<span class="chip chip-red">✗ Fail</span>'}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
async function cleanupUnused(){
|
|
document.getElementById('cleanupUnusedOut').textContent='Processing...';
|
|
const r=await fetch('/cleanup/unused',{method:'POST'});
|
|
document.getElementById('cleanupUnusedOut').textContent=await j(r);
|
|
}
|
|
async function cleanupLogs(){
|
|
document.getElementById('cleanupLogsOut').textContent='Processing...';
|
|
const days=document.getElementById('cleanDays').value||30;
|
|
const r=await fetch('/cleanup/old_logs?days='+days,{method:'POST'});
|
|
document.getElementById('cleanupLogsOut').textContent=await j(r);
|
|
}
|
|
async function deleteByName(){
|
|
const name=document.getElementById('delName').value.trim();
|
|
if(!name){alert('Isi nama dulu');return;}
|
|
document.getElementById('deleteOut').textContent='Processing...';
|
|
const r=await fetch('/person/delete?name='+encodeURIComponent(name),{method:'DELETE'});
|
|
document.getElementById('deleteOut').textContent=await j(r);
|
|
}
|
|
async function deleteByUid(){
|
|
const uid=document.getElementById('delUid').value.trim();
|
|
if(!uid){alert('Isi UID dulu');return;}
|
|
document.getElementById('deleteOut').textContent='Processing...';
|
|
const r=await fetch('/person/delete?uid='+encodeURIComponent(uid),{method:'DELETE'});
|
|
document.getElementById('deleteOut').textContent=await j(r);
|
|
}
|
|
</script>
|
|
"""
|
|
|
|
|
|
@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 """<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>ESP32 Live</title>
|
|
<style>body{font-family:system-ui;margin:16px;background:#0b0f14;color:#e6edf3}img{max-width:100%;border-radius:10px;border:1px solid #1f2a37;margin-top:12px}button{padding:8px 14px;border-radius:8px;border:1px solid #2a3a4f;background:#162233;color:#e6edf3;cursor:pointer}</style>
|
|
</head>
|
|
<body>
|
|
<h2>ESP32 Live Preview</h2>
|
|
<button onclick="toggleAnnotate()">Toggle Annotate</button>
|
|
<span id="status"></span>
|
|
<br/>
|
|
<img id="img" src="/latest.jpg?annotate=1" alt="latest frame"/>
|
|
<script>
|
|
let annotate=true;
|
|
const img=document.getElementById("img");
|
|
function toggleAnnotate(){annotate=!annotate;refresh(true);}
|
|
function refresh(force){
|
|
const url="/latest.jpg"+(annotate?"?annotate=1":"")+(annotate?"&":"?")+"t="+Date.now();
|
|
img.src=url;
|
|
if(force) document.getElementById("status").textContent="refreshing...";
|
|
}
|
|
setInterval(()=>refresh(false),500);
|
|
img.onload=()=>document.getElementById("status").textContent="";
|
|
</script>
|
|
<p style="margin-top:12px;opacity:.7">ESP32 POST JPEG ke <code>/frame</code>.</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@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)
|