EALPR_FLASK / templates /security /dashboard.html
Pant0x's picture
Final synced deploy v2 - Fix session and timeouts
cd01269
Raw
History Blame Contribute Delete
30.7 kB
{% extends "base.html" %}
{% block title %}Security Command Center β€” Ain El Aql{% endblock %}
{% block head_extra %}
<style>
/* Premium Red override for Unregistered */
.status-pill.guest {
background: rgba(239, 68, 68, 0.15) !important;
color: #ef4444 !important;
border: 1px solid rgba(239, 68, 68, 0.3) !important;
}
.form-control-glass {
background: rgba(0, 0, 0, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem;
width: 100%;
}
.form-control-glass option {
background: #1a2236;
color: white;
}
</style>
{% endblock %}
{% block body %}
<div class="app-layout">
{% with role='security', active='dashboard' %}
{% include "partials/sidebar.html" %}
{% endwith %}
<main class="main-content">
<div class="page-header flex-between fade-in">
<div>
<h1 class="page-title">Command Center</h1>
<p class="page-subtitle">Real-time facility monitoring & gate control</p>
</div>
<div class="pulse-badge">
<div class="pulse-dot"></div>
System Live
</div>
</div>
<!-- Stats -->
<div class="stats-grid fade-in">
<div class="stat-card blue">
<div class="stat-label">Total Capacity</div>
<div class="stat-value" id="statCapacity">1000</div>
<div class="stat-detail">Total slots</div>
</div>
<div class="stat-card green">
<div class="stat-label">Currently Inside</div>
<div class="stat-value" id="statInside">0</div>
<div class="stat-detail">Active vehicles</div>
</div>
<div class="stat-card purple">
<div class="stat-label">Available</div>
<div class="stat-value" id="statAvailable">1000</div>
<div class="stat-detail">Free spots</div>
</div>
</div>
<!-- Gate Control Hub -->
<div class="glass-card mb-4 fade-in" id="gateHub"
style="border-left: 4px solid var(--accent-blue); transition: all 0.3s ease;">
<div class="card-header py-3 flex-between">
<div class="d-flex align-items-center gap-3">
<div id="gateIndicator" class="pulse-dot" style="background:var(--accent-blue); width:12px; height:12px;">
</div>
<div>
<h2 class="card-title" id="gateStatusText" style="font-size:1rem;">System Ready β€” Monitoring Gate</h2>
<p id="gateSubtext" style="font-size:0.75rem; color:var(--text-muted); margin:0;">Waiting for next vehicle
scan...</p>
</div>
</div>
<div style="display:flex; align-items:center; gap:1.5rem;">
<div id="gateActionArea"></div>
<div id="gateTimer" class="mono-bold" style="font-size:1.5rem; color:var(--accent-blue); display:none;">04s</div>
</div>
</div>
</div>
<div class="content-grid">
<!-- Left: Scanning Activity & Camera -->
<div class="glass-card fade-in" style="display:flex; flex-direction:column; gap:1.5rem;">
<div>
<div class="card-header mb-2">
<h2 class="card-title">πŸ“· Gate Scanner</h2>
</div>
<form id="uploadForm">
<input type="hidden" name="parking_location" value="OPERA">
<div style="display:flex; gap:1rem; margin-bottom:1rem;">
<div class="w-full">
<label style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.3rem; display:block;">Gate Mode</label>
<select name="event_type" id="eventTypeSelect" class="form-control-glass">
<option value="entry">🟒 Entry Gate</option>
<option value="exit">πŸ”΄ Exit Gate</option>
</select>
</div>
<div class="w-full">
<label style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.3rem; display:block;">Upload Photo</label>
<input type="file" name="image" accept="image/*" class="form-control-glass" id="fileInput">
</div>
</div>
</form>
<div id="scanFeedback" class="mt-3 p-2 text-center fade-in" style="display:none; background:rgba(255,255,255,0.05); border-radius:8px; border:1px solid rgba(255,255,255,0.1); min-height:40px;">
</div>
</div>
<!-- Camera Controls -->
<div class="camera-controls mt-2" style="display:flex; gap:0.5rem;">
<button class="btn btn-outline btn-sm w-full" id="btnCamStart" onclick="startCamera()"><span>πŸ“Ή</span> Open Camera (Auto-Scan)</button>
<button class="btn btn-danger btn-sm w-full" id="btnCamStop" onclick="stopCamera()" style="display:none;"><span>⏹</span> Stop Camera</button>
</div>
<div class="camera-feed mt-2" id="cameraFeed" style="display:none; position:relative; height: 350px; background:#000; border-radius:12px; overflow:hidden;">
<video id="camVideo" autoplay playsinline muted style="width:100%; height:100%; object-fit: cover;"></video>
<canvas id="camCanvas" style="display:none;"></canvas>
<div style="position:absolute; top:10px; left:10px; background:rgba(239,68,68,0.8); color:white; padding:2px 8px; border-radius:4px; font-size:0.7rem; font-weight:bold; animation:pulse 1s infinite;">REC</div>
</div>
<!-- ESP32 Gate Connection -->
<div class="mt-2" style="display:flex; gap:0.5rem; align-items:center;">
<button class="btn btn-outline btn-sm" id="btnEspConnect" onclick="connectESP()" style="flex:1;">
<span>πŸ”Œ</span> Connect ESP32 (USB)
</button>
<button class="btn btn-danger btn-sm" id="btnEspDisconnect" onclick="disconnectESP()" style="flex:1; display:none;">
<span>β›”</span> Disconnect ESP32
</button>
<div id="espStatus" style="font-size:0.7rem; color:var(--text-muted); padding:4px 8px; background:rgba(255,255,255,0.05); border-radius:6px; white-space:nowrap;">βšͺ No ESP</div>
</div>
</div>
<!-- Right: Active Garage List -->
<div class="glass-card fade-in">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h2 class="card-title">πŸš— Inside Garage</h2>
<button onclick="localStorage.removeItem('recent_scans'); refreshDashboard();" class="btn-sm" style="background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:var(--text-muted); padding:2px 8px; border-radius:4px; font-size:0.65rem; cursor:pointer;">
🧹 Clear Cache
</button>
</div>
<span class="card-badge badge-ok">Real-time</span>
</div>
<div class="table-wrapper" style="max-height: 500px; overflow-y:auto;">
<table>
<thead>
<tr>
<th>OCR Plate</th>
<th>Status</th>
<th>Duration</th>
<th>Est. Fee</th>
<th style="text-align:right;">Action</th>
<th style="text-align:center;">Payment</th>
</tr>
</thead>
<tbody id="insideCarsBody">
<tr><td colspan="6" class="text-center" style="padding:4rem;">Connecting to garage feed...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
<script>
let activeGateSession = null;
let activeGatePlate = null;
let activeGateMode = null;
let activeTimer = null;
let autoScanTimer = null;
let isAutoScanning = false;
let espPort = null;
let espWriter = null;
// Persistence: Remember exited cars even after refresh
function getPersistentExits() {
const data = JSON.parse(localStorage.getItem('exited_plates_log') || '{}');
const now = Date.now();
// Only keep exits from the last 30 minutes
const filtered = {};
for (const [p, time] of Object.entries(data)) {
if (now - time < 1800000) filtered[p] = time;
}
return filtered;
}
function addPersistentExit(plate) {
const data = getPersistentExits();
data[normalizePlate(plate)] = Date.now();
localStorage.setItem('exited_plates_log', JSON.stringify(data));
}
document.addEventListener('DOMContentLoaded', () => {
refreshDashboard();
setInterval(refreshDashboard, 2000); // 2 seconds for high-tier real-time feel
const fileInput = document.getElementById('fileInput');
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
const formData = new FormData(document.getElementById('uploadForm'));
processScan(formData);
}
});
}
const uploadForm = document.getElementById('uploadForm');
if (uploadForm) {
uploadForm.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
processScan(formData);
});
}
// Smooth UI timer loop
setInterval(() => {
document.querySelectorAll('.live-timer').forEach(el => {
const paidTime = new Date(el.getAttribute('data-paid')).getTime();
const diffSec = Math.floor((5 * 60 * 1000 - (Date.now() - paidTime)) / 1000);
if (diffSec > 0) {
const m = Math.floor(diffSec / 60);
const sec = diffSec % 60;
el.innerHTML = `⏱️ ${m}:${sec.toString().padStart(2, '0')} TO EXIT`;
} else {
el.innerHTML = `OVERSTAYED`;
el.style.color = '#ef4444';
}
});
}, 1000);
});
async function refreshDashboard() {
// Don't overwrite the table while the gate timer is actively counting down
if (activeTimer) return;
try {
const [occR, insR] = await Promise.all([
fetch('/security/api/occupancy'),
fetch('/security/api/inside-cars')
]);
// Re-check after await β€” timer may have started while we were fetching
if (activeTimer) return;
const occ = await occR.json();
const ins = await insR.json();
console.log('[DEBUG] inside-cars raw response:', JSON.stringify(ins).substring(0, 500));
const g = occ.garage || occ;
const capacity = g.capacity || g.total_capacity || 1000;
const finalItems = renderGarageTable(ins);
console.log('[DEBUG] renderGarageTable returned', finalItems.length, 'items');
document.getElementById('statCapacity').textContent = capacity;
document.getElementById('statInside').textContent = finalItems.length;
document.getElementById('statAvailable').textContent = Math.max(0, capacity - finalItems.length);
} catch (e) { console.error("Refresh error:", e); }
}
function normalizePlate(p) {
if (!p) return '';
const map = {'Ω ':'0','Ω‘':'1','Ω’':'2','Ω£':'3','Ω€':'4','Ω₯':'5','Ω¦':'6','Ω§':'7','Ω¨':'8','Ω©':'9'};
return String(p).replace(/[Ω -Ω©]/g, d => map[d]).replace(/[\s\-\|]/g, '').trim();
}
function toArabicDigits(str) {
if (!str) return '';
const map = {'0':'Ω ','1':'Ω‘','2':'Ω’','3':'Ω£','4':'Ω€','5':'Ω₯','6':'Ω¦','7':'Ω§','8':'Ω¨','9':'Ω©'};
return String(str).replace(/[0-9]/g, d => map[d]);
}
function formatPlateVisual(str) {
if (!str) return '';
let s = toArabicDigits(str);
// Split by common dividers
let parts = s.split(/[\|]/).map(p => p.trim());
if (parts.length >= 2) {
// Space out characters in each part, filtering out Kashidas/Tatweel (Ω€) that break visual spacing
let letters = parts[0].split('').filter(c => c !== ' ' && c !== 'Ω€').join(' ');
let numbers = parts[1].split('').filter(c => c !== ' ' && c !== 'Ω€').join(' ');
return `${letters} | ${numbers}`;
}
// Fallback: space out everything, filtering out Kashidas
return s.split('').filter(c => c !== ' ' && c !== 'Ω€').join(' ').trim();
}
function formatDuration(mins) {
if (mins == null) return 'β€”';
const h = Math.floor(mins / 60);
const m = Math.floor(mins % 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function renderGarageTable(items) {
const body = document.getElementById('insideCarsBody');
const data = Array.isArray(items) ? items : (items.data || items.items || []);
const persistentExits = getPersistentExits();
const mergedItems = data.filter(item => {
const s = item.session || {};
const v = item.vehicle || {};
// Unified plate extraction logic
let pRaw = v.plate_arabic || "";
if (!pRaw && (v.plate_letters_ar || v.plate_numbers_ar)) {
pRaw = (v.plate_letters_ar || '') + ' | ' + (v.plate_numbers_ar || '');
}
if (!pRaw) {
// Check session, then item, then plate objects
pRaw = s.plate_arabic || item.plate_arabic ||
s.plate_text_ar || item.plate_text_ar ||
s.plate_ar || item.plate_ar ||
s.plate_text || item.plate_text ||
s.plate_key || item.plate_key ||
(item.plate && typeof item.plate === 'object' ? (item.plate.arabic || item.plate.plate_ar) : item.plate) ||
'';
}
item._computed_plate = pRaw.replace(/car|vehicle|lp|en|ar/gi, '').trim();
if (item._computed_plate === '|') item._computed_plate = s.plate_key || item.plate_key || '';
const plateNorm = normalizePlate(item._computed_plate);
return !persistentExits[plateNorm];
});
if (!mergedItems.length) {
body.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:3rem; color:var(--text-muted);">Garage is currently empty.</td></tr>';
return [];
}
body.innerHTML = mergedItems.map(item => {
const s = item.session || item || {};
const v = item.vehicle || {};
const p = item.pricing || {};
let plate = formatPlateVisual(item._computed_plate) || 'β€”';
const isGuest = !v.id && !v.plate_letters_ar && !v.id_number; // Robust guest check
const duration = formatDuration(p.duration_minutes);
const fee = p.total_fee_egp != null ? p.total_fee_egp : '0.00';
const sid = s.id || s.session_id || '';
let actionHtml = '<td style="text-align:right; color:var(--text-muted); font-size:0.8rem;">Monitoring...</td>';
const notes = (s.notes || '').toLowerCase();
const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
const isPaid = s.payment_confirmed_at != null;
let isCash = isGuest || hasCashIntent;
let isPaidVisa = isPaid && !isCash;
if ((isPaid || hasCashIntent) && !isCash) {
actionHtml = `<td class="live-timer" data-paid="${s.payment_confirmed_at || new Date().toISOString()}" style="text-align:right; color:var(--accent-green); font-weight:bold; font-size:0.9rem;">Calculating...</td>`;
} else if (isCash) {
actionHtml = `<td style="text-align:right; color:var(--accent-green); font-weight:bold; font-size:0.9rem;">CASH READY</td>`;
} else if (s.status === 'overstayed' || item.payment_status === 'paid_grace_period_expired') {
actionHtml = `<td style="text-align:right; color:#ef4444; font-weight:bold; font-size:0.9rem;">OVERSTAYED</td>`;
}
let paymentHtml = '<td style="text-align:center; color:var(--text-muted); font-size:0.8rem;">β€”</td>';
if (isCash) {
paymentHtml = `<td style="text-align:center;"><span style="background:rgba(234, 179, 8, 0.2); color:#eab308; padding:2px 8px; border-radius:12px; font-size:0.75rem; font-weight:bold; border:1px solid rgba(234, 179, 8, 0.3);">πŸ’΅ Cash</span></td>`;
} else if (isPaidVisa) {
paymentHtml = `<td style="text-align:center;"><span style="background:rgba(59, 130, 246, 0.2); color:#3b82f6; padding:2px 8px; border-radius:12px; font-size:0.75rem; font-weight:bold; border:1px solid rgba(59, 130, 246, 0.3);">πŸ’³ Visa</span></td>`;
}
return `
<tr id="row-${sid}" class="session-row">
<td class="mono-bold" style="font-size:1.1rem; color:white;">${plate.replace('car','').trim()}</td>
<td><span class="status-pill ${isGuest ? 'guest' : 'reg'}">${isGuest ? 'UNREGISTERED' : 'REGISTERED'}</span></td>
<td>${duration}</td>
<td style="color:var(--accent-green); font-weight:bold;">${fee} EGP</td>
${actionHtml}
${paymentHtml}
</tr>`;
}).join('');
return mergedItems;
}
async function processScan(formData) {
const feedback = document.getElementById('scanFeedback');
feedback.style.display = 'block';
feedback.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Processing...';
// Reset file input so the same file can be selected again
const fileInput = document.getElementById('fileInput');
if (fileInput) fileInput.value = '';
try {
const resp = await fetch(window.location.href, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
});
const res = await resp.json();
if (res.error) {
feedback.innerHTML = `<span style="color:#ef4444;">❌ OCR Failed: ${res.error}</span>`;
setTimeout(() => { feedback.style.display = 'none'; }, 3000);
} else {
feedback.innerHTML = `🎯 Detected: <span style="color:white;">${formatPlateVisual(res.plate_text_ar || res.plate)}</span> β€” <span style="color:var(--accent-green);">Done</span>`;
handleGateScanResult(res, formData.get('event_type') || 'entry');
setTimeout(() => { feedback.style.display = 'none'; }, 3000);
}
} catch (e) {
feedback.innerHTML = `<span style="color:#ef4444;">❌ Connection Error</span>`;
setTimeout(() => { feedback.style.display = 'none'; }, 3000);
}
}
function handleGateScanResult(res, mode) {
const hub = document.getElementById('gateHub'), status = document.getElementById('gateStatusText'), subtext = document.getElementById('gateSubtext'), indicator = document.getElementById('gateIndicator');
const rawPlate = res.plate_text_ar || res.plate || 'Unknown';
const plate = formatPlateVisual(rawPlate);
const norm = normalizePlate(rawPlate);
const sid = res.session_id || res.session?.id || res.supabase?.session_id || res.session?.session_id || '';
const isReg = res.app_registration?.is_registered || res.matched;
activeGateSession = sid;
activeGatePlate = norm;
if (mode === 'entry') {
// Clear ALL persistent exits β€” this car is re-entering, any stale cache must go
localStorage.removeItem('exited_plates_log');
activeGateMode = 'entry';
hub.style.borderLeftColor = 'var(--accent-blue)'; indicator.style.background = 'var(--accent-blue)';
status.innerHTML = `🟒 ENTRY β€” ${plate}`; subtext.innerText = isReg ? 'Member Entry' : 'Guest Entry';
document.getElementById('gateActionArea').innerHTML = '';
const body = document.getElementById('insideCarsBody');
const row = `<tr>
<td class="mono-bold" style="font-size:1.1rem; color:white;">${plate}</td>
<td><span class="status-pill ${isReg ? 'reg' : 'guest'}">${isReg ? 'REGISTERED' : 'UNREGISTERED'}</span></td>
<td>Just now</td>
<td style="color:var(--accent-green); font-weight:bold;">0.00 EGP</td>
<td style="text-align:right; color:var(--accent-blue); font-size:0.8rem; font-weight:bold;">SYNCING...</td>
</tr>`;
if (body.innerText.includes('empty')) body.innerHTML = '';
body.insertAdjacentHTML('afterbegin', row);
startHubTimer(10, plate, 'blue');
sendToESP('1'); // Open the physical gate via USB
fetch('/security/api/gate-open', {method: 'POST'}); // Open the physical gate via Cloud WiFi
} else {
const isAllowed = res.gate_decision?.allowed;
const fee = res.pricing?.total_fee_egp || res.fee || '0.00';
if (isAllowed) {
// Auto-Exit for paid/subscription members
confirmHubPayment(sid, plate);
} else {
// Payment required for both unregistered and unpaid registered members
hub.style.borderLeftColor = 'var(--accent-red)'; indicator.style.background = 'var(--accent-red)';
status.innerHTML = `πŸ”΄ PAYMENT REQUIRED β€” ${plate}`;
subtext.innerText = isReg ? `Member Balance: ${fee} EGP` : `Guest Fee: ${fee} EGP`;
const actionArea = document.getElementById('gateActionArea');
actionArea.innerHTML = '';
const btn = document.createElement('button');
btn.className = 'btn-primary';
btn.style = 'background:var(--accent-green); width:100%; padding:15px; font-weight:bold; border-radius:12px; margin-top:10px; cursor:pointer; font-size:1rem; box-shadow: 0 4px 15px rgba(0,255,100,0.3);';
btn.innerHTML = 'πŸ’³ CONFIRM CASH PAYMENT & EXIT';
btn.onclick = () => confirmHubPayment(sid, plate);
actionArea.appendChild(btn);
refreshDashboard();
}
}
}
async function confirmHubPayment(sid, plate) {
const hub = document.getElementById('gateHub'), indicator = document.getElementById('gateIndicator');
const status = document.getElementById('gateStatusText');
const norm = normalizePlate(plate);
hub.style.borderLeftColor = 'var(--accent-green)'; indicator.style.background = 'var(--accent-green)';
document.getElementById('gateActionArea').innerHTML = '';
document.getElementById('gateSubtext').innerText = 'Exit Authorized β€” Drive Safe';
activeGateMode = 'exit';
addPersistentExit(plate);
activeGateSession = sid;
activeGatePlate = norm;
// START TIMER IMMEDIATELY
startHubTimer(10, plate, 'green');
// Send gate open to ESP32 via USB serial
sendToESP('1');
// Send gate open to ESP32 via Cloud WiFi
fetch('/security/api/gate-open', {method: 'POST'});
try {
const resp = await fetch('/security/api/cash-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sid,
plate_arabic: plate,
plate_key: activeGatePlate, // We use the normalized plate as key
parking_location: document.getElementById('parking_location')?.value || 'OPERA'
})
});
const d = await resp.json();
} catch(e) { console.error("Database checkout failed", e); }
}
function startHubTimer(sec, plate, colorType = 'green') {
const hubEl = document.getElementById('gateHub');
const status = document.getElementById('gateStatusText');
const indicator = document.getElementById('gateIndicator');
const timerElem = document.getElementById('gateTimer');
const color = colorType === 'blue' ? 'var(--accent-blue)' : 'var(--accent-green)';
const bg = colorType === 'blue' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(16, 185, 129, 0.1)';
const shadow = colorType === 'blue' ? 'rgba(59, 130, 246, 0.4)' : 'rgba(16, 185, 129, 0.4)';
if (activeTimer) clearInterval(activeTimer);
hubEl.style.boxShadow = `0 0 30px ${shadow}`;
hubEl.style.borderColor = color;
timerElem.style.display = 'flex';
timerElem.style.alignItems = 'center';
timerElem.style.justifyContent = 'center';
timerElem.style.position = 'absolute';
timerElem.style.top = '1.5rem';
timerElem.style.right = '1.5rem';
timerElem.style.width = '60px';
timerElem.style.height = '60px';
timerElem.style.borderRadius = '50%';
timerElem.style.background = bg;
timerElem.style.border = `2px solid ${color}`;
timerElem.style.boxShadow = `0 0 20px ${shadow}`;
timerElem.style.color = color;
timerElem.style.fontSize = '1.4rem';
timerElem.style.fontWeight = '900';
indicator.style.animation = 'pulse 1s infinite';
indicator.style.boxShadow = `0 0 15px ${color}`;
let left = sec;
const update = () => {
timerElem.innerText = left + 's';
status.innerHTML = colorType === 'blue' ? `🟒 ENTRY β€” ${plate}` : `βœ… OPEN β€” ${plate}`;
};
update();
activeTimer = setInterval(() => {
left--;
if (left <= 0) {
clearInterval(activeTimer); activeTimer = null;
resetHub();
} else {
update();
}
}, 1000);
}
function resetHub() {
const hub = document.getElementById('gateHub');
const status = document.getElementById('gateStatusText');
const timerElem = document.getElementById('gateTimer');
const indicator = document.getElementById('gateIndicator');
const wasExit = activeGateMode === 'exit';
timerElem.style.display = 'none';
indicator.style.animation = 'none';
indicator.style.boxShadow = 'none';
hub.style.borderLeftColor = 'var(--accent-blue)';
hub.style.borderColor = 'rgba(255,255,255,0.05)';
hub.style.boxShadow = 'none';
indicator.style.background = 'var(--accent-blue)';
status.innerHTML = 'πŸ›‘οΈ GATE SECURE β€” <span style="color:var(--text-muted);">SYSTEM READY</span>';
document.getElementById('gateSubtext').innerText = 'Waiting for vehicle detection...';
document.getElementById('gateActionArea').innerHTML = '';
activeGateSession = null;
activeGatePlate = null;
activeGateMode = null;
// Only rebuild table on exit (car leaving). On entry, keep the row and let auto-refresh sync it.
if (wasExit) refreshDashboard();
}
function calcDuration(start) {
const diff = Math.floor((Date.now() - new Date(start).getTime()) / 60000);
return diff > 60 ? `${Math.floor(diff/60)}h ${diff%60}m` : `${diff}m`;
}
function captureFrameAuto() {
if (isAutoScanning) return;
const video = document.getElementById('camVideo'), canvas = document.getElementById('camCanvas');
if (video.videoWidth === 0) return;
isAutoScanning = true;
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const formData = new FormData();
formData.append('camera_frame', canvas.toDataURL('image/jpeg', 0.9));
formData.append('event_type', document.getElementById('eventTypeSelect').value);
formData.append('parking_location', 'OPERA');
processAutoScan(formData);
}
async function processAutoScan(formData) {
try {
const resp = await fetch(window.location.href, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
});
const res = await resp.json();
if (res.error || res.reason === 'plate_not_detected') {
return;
}
const plate = res.plate_text_ar || res.plate || 'Unknown';
// Completely ignore empty, N/A, or Unknown plates
if (!plate || plate.toUpperCase() === 'N/A' || plate.toUpperCase() === 'UNKNOWN') {
return;
}
const norm = normalizePlate(plate);
// Only trigger the gate workflow if it's a new car we haven't just processed
if (norm !== activeGatePlate) {
handleGateScanResult(res, formData.get('event_type') || 'entry');
}
} catch (e) {
// Silent fail on network error for auto-scan
} finally {
isAutoScanning = false;
}
}
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
document.getElementById('cameraFeed').style.display = 'block';
document.getElementById('camVideo').srcObject = stream;
document.getElementById('btnCamStart').style.display = 'none';
document.getElementById('btnCamStop').style.display = 'block';
window.streamObj = stream;
// Auto-scan every 2.5 seconds
if (autoScanTimer) clearInterval(autoScanTimer);
autoScanTimer = setInterval(captureFrameAuto, 2500);
} catch (e) { alert("Camera access denied."); }
}
function stopCamera() {
if (window.streamObj) window.streamObj.getTracks().forEach(t => t.stop());
if (autoScanTimer) { clearInterval(autoScanTimer); autoScanTimer = null; }
document.getElementById('cameraFeed').style.display = 'none';
document.getElementById('btnCamStart').style.display = 'block';
document.getElementById('btnCamStop').style.display = 'none';
}
// ── ESP32 Web Serial API ──────────────────────────────────
async function connectESP() {
if (!('serial' in navigator)) {
alert('Web Serial API not supported in this browser. Use Chrome or Edge.');
return;
}
try {
espPort = await navigator.serial.requestPort();
await espPort.open({ baudRate: 115200 });
espWriter = espPort.writable.getWriter();
document.getElementById('btnEspConnect').style.display = 'none';
document.getElementById('btnEspDisconnect').style.display = 'block';
document.getElementById('espStatus').innerHTML = '🟒 ESP32 Connected';
document.getElementById('espStatus').style.color = 'var(--accent-green)';
console.log('[ESP] Connected via USB Serial');
} catch (e) {
console.error('[ESP] Connection failed:', e);
document.getElementById('espStatus').innerHTML = 'πŸ”΄ Failed';
document.getElementById('espStatus').style.color = '#ef4444';
}
}
async function disconnectESP() {
try {
if (espWriter) { espWriter.releaseLock(); espWriter = null; }
if (espPort) { await espPort.close(); espPort = null; }
} catch (e) { console.error('[ESP] Disconnect error:', e); }
document.getElementById('btnEspConnect').style.display = 'block';
document.getElementById('btnEspDisconnect').style.display = 'none';
document.getElementById('espStatus').innerHTML = 'βšͺ No ESP';
document.getElementById('espStatus').style.color = 'var(--text-muted)';
console.log('[ESP] Disconnected');
}
async function sendToESP(command) {
if (!espWriter) return;
try {
const encoder = new TextEncoder();
await espWriter.write(encoder.encode(command));
console.log('[ESP] Sent command:', command);
// Flash the status indicator
const statusEl = document.getElementById('espStatus');
statusEl.innerHTML = '⚑ GATE OPENING';
statusEl.style.color = '#eab308';
setTimeout(() => {
statusEl.innerHTML = '🟒 ESP32 Connected';
statusEl.style.color = 'var(--accent-green)';
}, 2000);
} catch (e) {
console.error('[ESP] Send failed:', e);
disconnectESP();
}
}
</script>
{% endblock %}