Spaces:
Sleeping
Sleeping
| {% 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) ; | |
| color: #ef4444 ; | |
| border: 1px solid rgba(239, 68, 68, 0.3) ; | |
| } | |
| .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 %} |