| |
| |
|
|
| SENSOR_BRIDGE_HTML = """ |
| <div id="sensor-status-indicator" style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 13px;"> |
| <span id="status-dot" style="width: 10px; height: 10px; border-radius: 50%; background: #ef4444; display: inline-block;"></span> |
| <span id="status-text" style="color: #9ca3af; font-weight: 500;">Telemetry: Offline</span> |
| </div> |
| |
| <!-- Hidden DOM elements used for camera stream capture --> |
| <video id="sentinel-video" autoplay playsinline style="display: none;"></video> |
| <canvas id="sentinel-canvas" style="display: none;"></canvas> |
| |
| <script> |
| (function() { |
| let videoStream = null; |
| let audioStream = null; |
| let audioCtx = null; |
| let analyser = null; |
| let dopplerOsc = null; |
| let dopplerGain = null; |
| let cameraActive = false; |
| let watchId = null; |
| let lightSensor = null; |
| let isActivated = false; |
| |
| // Audio volume monitoring variables |
| let loudAudioStartTime = null; |
| const LOUD_THRESHOLD = 0.08; // RMS volume threshold (0.0 to 1.0) |
| |
| // Telemetry throttling (send updates every 200ms to avoid flooding Gradio) |
| let lastUpdateTimes = { |
| motion: 0, |
| orientation: 0, |
| audio: 0 |
| }; |
| |
| console.log("Sentinel Sensor Bridge Initialized."); |
| |
| // Helper to safely set Gradio hidden component values in the DOM and trigger updates |
| function setGradioValue(elemId, value) { |
| const container = document.getElementById(elemId); |
| if (!container) return; |
| const input = container.querySelector("input") || container.querySelector("textarea"); |
| if (input) { |
| // Only update if value changed to prevent unnecessary Svelte re-renders |
| if (input.value !== String(value)) { |
| input.value = String(value); |
| // Dispatch event so Gradio's Svelte binding captures the change |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| } |
| } |
| |
| // --- 1. CAMERA STREAM (2 FPS) --- |
| async function startCamera() { |
| const video = document.getElementById("sentinel-video"); |
| const canvas = document.getElementById("sentinel-canvas"); |
| if (!video || !canvas) return; |
| |
| try { |
| videoStream = await navigator.mediaDevices.getUserMedia({ |
| video: { |
| facingMode: 'environment', // Request rear camera |
| width: { ideal: 640 }, |
| height: { ideal: 480 } |
| }, |
| audio: false |
| }); |
| video.srcObject = videoStream; |
| cameraActive = true; |
| logger("Camera active."); |
| |
| // Start the frame capture loop |
| requestAnimationFrame(captureFrame); |
| } catch (e) { |
| console.error("Camera access failed:", e); |
| logger("Camera error: " + e.message, true); |
| } |
| } |
| |
| function captureFrame() { |
| if (!cameraActive) return; |
| |
| const video = document.getElementById("sentinel-video"); |
| const canvas = document.getElementById("sentinel-canvas"); |
| |
| if (video && video.readyState === video.HAVE_ENOUGH_DATA) { |
| const ctx = canvas.getContext("2d"); |
| // Downscale to 320x240 for fast CPU differencing |
| canvas.width = 320; |
| canvas.height = 240; |
| |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
| |
| // Convert to base64 JPEG at 0.6 quality (compromise between size and visual cues) |
| const base64Data = canvas.toDataURL("image/jpeg", 0.6); |
| |
| // Extract raw base64 payload (strip header) |
| const rawBase64 = base64Data.split(",")[1]; |
| |
| // Update hidden Gradio textbox |
| setGradioValue("image-data", rawBase64); |
| setGradioValue("pipeline-stage", "yolo"); |
| } |
| |
| // Loop every 500ms (2 FPS) |
| setTimeout(() => { |
| if (cameraActive) { |
| requestAnimationFrame(captureFrame); |
| } |
| }, 500); |
| } |
| |
| // --- 2. MOTION (ACCELERATION) --- |
| async function requestMotionPermission() { |
| if (typeof DeviceMotionEvent !== 'undefined' && typeof DeviceMotionEvent.requestPermission === 'function') { |
| try { |
| const permission = await DeviceMotionEvent.requestPermission(); |
| if (permission === 'granted') { |
| window.addEventListener('devicemotion', handleMotion); |
| logger("Motion sensors active (iOS)."); |
| } else { |
| logger("Motion permission denied.", true); |
| } |
| } catch (e) { |
| logger("Motion init error: " + e.message, true); |
| } |
| } else { |
| // Android / Desktop |
| window.addEventListener('devicemotion', handleMotion); |
| logger("Motion sensors active."); |
| } |
| } |
| |
| function handleMotion(event) { |
| const now = Date.now(); |
| if (now - lastUpdateTimes.motion < 200) return; // Throttle to 5Hz |
| lastUpdateTimes.motion = now; |
| |
| const acc = event.accelerationIncludingGravity || event.acceleration; |
| if (acc) { |
| // Normalize values. Default to 0 if NaN. |
| const x = acc.x ? acc.x.toFixed(2) : "0.00"; |
| const y = acc.y ? acc.y.toFixed(2) : "0.00"; |
| const z = acc.z ? acc.z.toFixed(2) : "0.00"; |
| |
| setGradioValue("accel-x", x); |
| setGradioValue("accel-y", y); |
| setGradioValue("accel-z", z); |
| } |
| } |
| |
| // --- 3. ORIENTATION (COMPASS / TILT) --- |
| function startOrientation() { |
| window.addEventListener('deviceorientation', handleOrientation); |
| } |
| |
| function handleOrientation(event) { |
| const now = Date.now(); |
| if (now - lastUpdateTimes.orientation < 250) return; // Throttle to 4Hz |
| lastUpdateTimes.orientation = now; |
| |
| // alpha: rotation around z-axis (compass heading 0-360) |
| let heading = event.alpha; |
| |
| // iOS specific heading |
| if (event.webkitCompassHeading) { |
| heading = event.webkitCompassHeading; |
| } |
| |
| if (heading !== null && heading !== undefined) { |
| setGradioValue("heading-val", heading.toFixed(1)); |
| } |
| |
| if (event.beta !== null && event.beta !== undefined) { |
| setGradioValue("gyro-beta", event.beta.toFixed(2)); |
| } |
| |
| if (event.gamma !== null && event.gamma !== undefined) { |
| setGradioValue("gyro-gamma", event.gamma.toFixed(2)); |
| } |
| } |
| |
| // --- 4. GPS GEOLOCATION --- |
| function startGPS() { |
| if (navigator.geolocation) { |
| watchId = navigator.geolocation.watchPosition( |
| (pos) => { |
| const lat = pos.coords.latitude.toFixed(6); |
| const lon = pos.coords.longitude.toFixed(6); |
| setGradioValue("gps-lat", lat); |
| setGradioValue("gps-lon", lon); |
| }, |
| (err) => { |
| console.error("GPS Watch failed:", err); |
| logger("GPS: Unavailable", true); |
| }, |
| { enableHighAccuracy: true, timeout: 10000 } |
| ); |
| logger("GPS Active."); |
| } else { |
| logger("GPS unsupported.", true); |
| } |
| } |
| |
| // --- 5. AMBIENT LIGHT SENSOR --- |
| function startLightSensor() { |
| try { |
| if ('AmbientLightSensor' in window) { |
| lightSensor = new AmbientLightSensor(); |
| lightSensor.addEventListener('reading', () => { |
| setGradioValue("light-level", lightSensor.illuminance.toFixed(1)); |
| }); |
| lightSensor.addEventListener('error', (event) => { |
| console.error("AmbientLightSensor error:", event.error); |
| }); |
| lightSensor.start(); |
| logger("Light sensor active."); |
| } else { |
| logger("Light sensor unsupported."); |
| } |
| } catch (e) { |
| console.warn("AmbientLightSensor failed initialization:", e); |
| } |
| } |
| |
| // --- 6. BATTERY STATUS --- |
| async function startBatteryMonitoring() { |
| if (navigator.getBattery) { |
| try { |
| const battery = await navigator.getBattery(); |
| const updateBattery = () => { |
| const pct = Math.round(battery.level * 100); |
| setGradioValue("battery-pct", pct); |
| }; |
| updateBattery(); |
| battery.addEventListener('levelchange', updateBattery); |
| } catch (e) { |
| console.error("Battery API failed:", e); |
| } |
| } |
| } |
| |
| // --- 7. AUDIO VOLUME MONITOR (RMS) --- |
| async function startAudioCapture() { |
| try { |
| audioStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); |
| |
| // Initialize Web Audio Context |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const source = audioCtx.createMediaStreamSource(audioStream); |
| |
| analyser = audioCtx.createAnalyser(); |
| analyser.fftSize = 512; |
| source.connect(analyser); |
| |
| logger("Microphone active."); |
| monitorAudioVolume(); |
| } catch (e) { |
| console.error("Microphone access failed:", e); |
| logger("Mic error: " + e.message, true); |
| } |
| } |
| |
| function monitorAudioVolume() { |
| if (!audioCtx || !analyser) return; |
| |
| const bufferLength = analyser.frequencyBinCount; |
| const dataArray = new Uint8Array(bufferLength); |
| |
| const checkVolume = () => { |
| if (!audioCtx || !analyser) return; |
| |
| analyser.getByteTimeDomainData(dataArray); |
| |
| // Compute Root Mean Square (RMS) volume |
| let sum = 0; |
| for (let i = 0; i < bufferLength; i++) { |
| const val = (dataArray[i] - 128) / 128.0; |
| sum += val * val; |
| } |
| const rms = Math.sqrt(sum / bufferLength); |
| |
| const now = Date.now(); |
| if (now - lastUpdateTimes.audio > 200) { |
| lastUpdateTimes.audio = now; |
| // Scale RMS to a percentage for the UI |
| const volPct = Math.min(Math.round(rms * 100 * 5), 100); // amplify for visual readability |
| setGradioValue("audio-level", volPct); |
| } |
| |
| // Loud Audio detection logic: must be above threshold for > 1 second |
| if (rms > LOUD_THRESHOLD) { |
| if (loudAudioStartTime === null) { |
| loudAudioStartTime = Date.now(); |
| } else if (Date.now() - loudAudioStartTime > 1000) { |
| // Trigger alert flag |
| setGradioValue("loud-audio-flag", "true"); |
| } |
| } else { |
| loudAudioStartTime = null; |
| setGradioValue("loud-audio-flag", "false"); |
| } |
| |
| setTimeout(checkVolume, 100); // Check every 100ms |
| }; |
| |
| checkVolume(); |
| } |
| |
| // --- 8. ULTRASONIC DOPPLER MOTION SENSING (20kHz) --- |
| function startUltrasonicDoppler() { |
| if (!audioCtx) return; |
| |
| try { |
| logger("Starting ultrasonic Doppler sensor..."); |
| |
| // 1. Create a high-frequency oscillator near ultrasound limit (20 kHz) |
| dopplerOsc = audioCtx.createOscillator(); |
| dopplerOsc.type = "sine"; |
| dopplerOsc.frequency.value = 20000; // 20 kHz |
| |
| // 2. Control volume (low gain to keep it safe for pets/hearing range) |
| dopplerGain = audioCtx.createGain(); |
| dopplerGain.gain.value = 0.02; // Very quiet |
| |
| dopplerOsc.connect(dopplerGain); |
| dopplerGain.connect(audioCtx.destination); |
| dopplerOsc.start(); |
| |
| // 3. Monitor reflections in FFT bins around 20kHz |
| monitorDopplerReflections(); |
| } catch (e) { |
| console.warn("Ultrasonic Doppler failed initialization:", e); |
| } |
| } |
| |
| function monitorDopplerReflections() { |
| if (!audioCtx || !analyser || !dopplerOsc) return; |
| |
| const sampleRate = audioCtx.sampleRate; |
| const fftSize = analyser.fftSize; |
| const binHz = sampleRate / fftSize; |
| |
| // Find index of FFT bin closest to 20kHz |
| const targetBin = Math.round(20000 / binHz); |
| |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); |
| |
| const checkDoppler = () => { |
| if (!audioCtx || !analyser || !dopplerOsc) return; |
| |
| analyser.getByteFrequencyData(dataArray); |
| |
| // Check adjacent bins to see if energy is leaking (indicates Doppler frequency shift) |
| const leftBinVal = dataArray[targetBin - 1] || 0; |
| const rightBinVal = dataArray[targetBin + 1] || 0; |
| const centerBinVal = dataArray[targetBin] || 0; |
| |
| const sideEnergy = leftBinVal + rightBinVal; |
| |
| // If side energy is high relative to center energy, motion is shifting the frequency |
| if (centerBinVal > 30 && sideEnergy > centerBinVal * 0.4) { |
| setGradioValue("doppler-motion-flag", "true"); |
| } else { |
| setGradioValue("doppler-motion-flag", "false"); |
| } |
| |
| setTimeout(checkDoppler, 200); |
| }; |
| |
| checkDoppler(); |
| } |
| |
| // --- STATUS INDICATORS & STATE --- |
| function logger(msg, isError = false) { |
| const dot = document.getElementById("status-dot"); |
| const txt = document.getElementById("status-text"); |
| |
| if (dot && txt) { |
| if (isError) { |
| dot.style.background = "#ef4444"; // Red |
| txt.innerHTML = "Status: " + msg; |
| txt.style.color = "#f87171"; |
| } else { |
| dot.style.background = "#22c55e"; // Green |
| txt.innerHTML = "Telemetry: " + msg; |
| txt.style.color = "#34d399"; |
| } |
| } |
| } |
| |
| // --- MAIN CONTROLS --- |
| async function startAllSensors() { |
| logger("Initializing telemetry..."); |
| isActivated = true; |
| |
| // Start non-permission variables |
| startOrientation(); |
| startBatteryMonitoring(); |
| |
| // Request GPS |
| startGPS(); |
| |
| // Request Camera (triggers capture loops) |
| await startCamera(); |
| |
| // Request Microphone (triggers volume loops) |
| await startAudioCapture(); |
| |
| // Request Motion / Accel (requires iOS permission popup) |
| await requestMotionPermission(); |
| |
| // Start experimental Doppler if audio context loaded |
| startUltrasonicDoppler(); |
| |
| // Start Chrome-specific Light sensor |
| startLightSensor(); |
| |
| // Update Gradio active state |
| setGradioValue("sentinel-active-state", "true"); |
| } |
| |
| function stopAllSensors() { |
| logger("Offline"); |
| isActivated = false; |
| cameraActive = false; |
| |
| // Stop Camera Streams |
| if (videoStream) { |
| videoStream.getTracks().forEach(track => track.stop()); |
| videoStream = null; |
| } |
| |
| // Stop Mic Streams |
| if (audioStream) { |
| audioStream.getTracks().forEach(track => track.stop()); |
| audioStream = null; |
| } |
| |
| // Stop Doppler Oscillator |
| if (dopplerOsc) { |
| try { dopplerOsc.stop(); } catch(e) {} |
| dopplerOsc = null; |
| } |
| |
| // Stop Geolocation watch |
| if (watchId !== null) { |
| navigator.geolocation.clearWatch(watchId); |
| watchId = null; |
| } |
| |
| // Stop Light Sensor |
| if (lightSensor) { |
| try { lightSensor.stop(); } catch(e) {} |
| lightSensor = null; |
| } |
| |
| // Stop listeners |
| window.removeEventListener('devicemotion', handleMotion); |
| window.removeEventListener('deviceorientation', handleOrientation); |
| |
| // Update Gradio active state |
| setGradioValue("sentinel-active-state", "false"); |
| } |
| |
| // --- VISUAL UPGRADES: SVG DETECTIONS, COST TICKER, HAPTICS --- |
| |
| function drawDetections(dets) { |
| let svg = document.getElementById("sentinel-svg-overlay"); |
| if (!svg) { |
| const parent = document.getElementById("camera-feed"); |
| if (!parent) return; |
| parent.style.position = "relative"; |
| svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| svg.id = "sentinel-svg-overlay"; |
| svg.setAttribute("viewBox", "0 0 320 240"); |
| svg.style.position = "absolute"; |
| svg.style.top = "0"; |
| svg.style.left = "0"; |
| svg.style.width = "100%"; |
| svg.style.height = "100%"; |
| svg.style.pointerEvents = "none"; |
| svg.style.zIndex = "10"; |
| parent.appendChild(svg); |
| } |
| |
| svg.innerHTML = ""; |
| if (!dets || dets.length === 0) return; |
| |
| dets.forEach(det => { |
| const [x1, y1, x2, y2] = det.bbox; |
| const width = x2 - x1; |
| const height = y2 - y1; |
| |
| let color = "#10b981"; // green |
| if (det.class_name === "person") color = "#3b82f6"; // blue |
| else if (det.class_name === "vehicle" || det.class_name === "car" || det.class_name === "truck") color = "#ef4444"; // red |
| else if (det.class_name === "fire" || det.class_name === "smoke") color = "#f97316"; // orange |
| |
| const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| |
| const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| const dVal = ` |
| M ${x1 + 10} ${y1} H ${x1} V ${y1 + 10} |
| M ${x2 - 10} ${y1} H ${x2} V ${y1 + 10} |
| M ${x1 + 10} ${y2} H ${x1} V ${y2 - 10} |
| M ${x2 - 10} ${y2} H ${x2} V ${y2 - 10} |
| `; |
| path.setAttribute("d", dVal); |
| path.setAttribute("stroke", color); |
| path.setAttribute("stroke-width", "2"); |
| path.setAttribute("fill", "none"); |
| g.appendChild(path); |
| |
| const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); |
| rect.setAttribute("x", x1); |
| rect.setAttribute("y", y1); |
| rect.setAttribute("width", width); |
| rect.setAttribute("height", height); |
| rect.setAttribute("fill", color); |
| rect.setAttribute("fill-opacity", "0.08"); |
| g.appendChild(rect); |
| |
| const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| text.setAttribute("x", x1 + 4); |
| text.setAttribute("y", y1 - 4 > 12 ? y1 - 4 : y1 + 14); |
| text.setAttribute("fill", color); |
| text.setAttribute("font-size", "10px"); |
| text.setAttribute("font-weight", "bold"); |
| text.setAttribute("font-family", "monospace"); |
| text.textContent = `${det.class_name} ${(det.confidence * 100).toFixed(0)}%`; |
| g.appendChild(text); |
| |
| svg.appendChild(g); |
| }); |
| } |
| |
| function bindDetectionDataObserver() { |
| const container = document.getElementById("detection-data"); |
| if (container) { |
| const textarea = container.querySelector("textarea") || container.querySelector("input"); |
| if (textarea) { |
| const observer = new MutationObserver(() => { |
| try { |
| const val = textarea.value; |
| if (val) drawDetections(JSON.parse(val)); |
| } catch (e) {} |
| }); |
| observer.observe(textarea, { attributes: true, attributeFilter: ['value'] }); |
| |
| let lastVal = ""; |
| setInterval(() => { |
| const val = textarea.value; |
| if (val !== lastVal) { |
| lastVal = val; |
| try { |
| if (val) drawDetections(JSON.parse(val)); |
| } catch (e) {} |
| } |
| }, 250); |
| |
| console.log("Sentinel detection-data observer bound."); |
| return; |
| } |
| } |
| setTimeout(bindDetectionDataObserver, 500); |
| } |
| |
| function bindAlertBannerObserver() { |
| const container = document.getElementById("alert-banner-container"); |
| if (container) { |
| const observer = new MutationObserver(() => { |
| const banner = container.querySelector(".alert-banner-critical, .alert-banner-warning"); |
| if (banner) { |
| if (banner.classList.contains('alert-banner-critical')) { |
| if (navigator.vibrate) navigator.vibrate([500]); |
| } else if (banner.classList.contains('alert-banner-warning')) { |
| if (navigator.vibrate) navigator.vibrate([200, 100, 200]); |
| } |
| } |
| }); |
| observer.observe(container, { childList: true, subtree: true }); |
| console.log("Sentinel alert banner observer bound for haptics."); |
| return; |
| } |
| setTimeout(bindAlertBannerObserver, 500); |
| } |
| |
| function updateCostTicker() { |
| const costInput = document.querySelector("#cost-total input"); |
| const frameInput = document.querySelector("#frame-count input"); |
| const ticker = document.getElementById("cost-ticker"); |
| |
| if (!ticker) return; |
| |
| let sentinelCost = 0.0; |
| let frameCount = 0; |
| |
| if (costInput && costInput.value) { |
| sentinelCost = parseFloat(costInput.value); |
| } |
| if (frameInput && frameInput.value) { |
| frameCount = parseInt(frameInput.value); |
| } |
| |
| const gptCost = frameCount * 0.01; |
| const savings = gptCost > 0 ? ((gptCost - sentinelCost) / gptCost * 100).toFixed(1) : "0.0"; |
| |
| ticker.innerHTML = ` |
| <div class="ticker-wrapper" style="display: flex; justify-content: space-between; align-items: center; background: rgba(30, 30, 50, 0.6); padding: 12px; border-radius: 8px; border: 1px solid rgba(99, 102, 241, 0.2); font-family: monospace; font-size: 14px; margin-top: 10px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);"> |
| <div class="ticker-col" style="flex: 1; text-align: left;"> |
| <div style="color: #9ca3af; font-size: 11px; text-transform: uppercase;">🛡️ Sentinel Cost</div> |
| <div style="font-size: 20px; font-weight: bold; color: #34d399; margin-top: 4px;">$${sentinelCost.toFixed(5)}</div> |
| </div> |
| <div class="ticker-arrow" style="padding: 0 16px; color: #6366f1; font-size: 16px; font-weight: bold; text-shadow: 0 0 8px rgba(99,102,241,0.5);"> |
| ← vs → |
| </div> |
| <div class="ticker-col" style="flex: 1; text-align: right;"> |
| <div style="color: #9ca3af; font-size: 11px; text-transform: uppercase;">🔴 Naive GPT-4o</div> |
| <div style="font-size: 20px; font-weight: bold; color: #f87171; margin-top: 4px;">$${gptCost.toFixed(4)}</div> |
| </div> |
| </div> |
| <div style="text-align: center; font-size: 12px; color: #818cf8; font-weight: 500; margin-top: 6px; text-shadow: 0 0 8px rgba(99, 102, 241, 0.4);"> |
| ⚡ Saving ${savings}% on API costs (Evaluating ${frameCount} frames) |
| </div> |
| `; |
| } |
| |
| function bindVoiceQueryButton() { |
| const btn = document.getElementById("voice-btn"); |
| if (!btn) { |
| setTimeout(bindVoiceQueryButton, 500); |
| return; |
| } |
| |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; |
| if (!SpeechRecognition) { |
| btn.innerHTML = "🎤 VOICE UNSUPPORTED"; |
| btn.disabled = true; |
| return; |
| } |
| |
| const recognition = new SpeechRecognition(); |
| recognition.continuous = false; |
| recognition.interimResults = false; |
| recognition.lang = 'en-US'; |
| |
| let isRecording = false; |
| |
| btn.addEventListener("click", () => { |
| if (!isRecording) { |
| try { |
| recognition.start(); |
| btn.innerHTML = "🔴 LISTENING..."; |
| btn.style.background = "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)"; |
| isRecording = true; |
| } catch (e) { |
| console.error("Speech recognition start failed:", e); |
| } |
| } else { |
| try { |
| recognition.stop(); |
| } catch (e) {} |
| } |
| }); |
| |
| recognition.onresult = (event) => { |
| const transcript = event.results[0][0].transcript; |
| console.log("Speech recognized:", transcript); |
| setGradioValue("voice-query", transcript); |
| }; |
| |
| recognition.onend = () => { |
| btn.innerHTML = "🎤 VOICE QUERY"; |
| btn.style.background = ""; |
| isRecording = false; |
| }; |
| |
| recognition.onerror = (event) => { |
| console.error("Speech recognition error:", event.error); |
| recognition.stop(); |
| }; |
| } |
| |
| function bindSOSButton() { |
| const btn = document.getElementById("sos-btn"); |
| if (!btn) { |
| setTimeout(bindSOSButton, 500); |
| return; |
| } |
| |
| btn.addEventListener("click", () => { |
| const latInput = document.querySelector("#gps-lat input"); |
| const lonInput = document.querySelector("#gps-lon input"); |
| |
| let lat = "unknown"; |
| let lon = "unknown"; |
| if (latInput && latInput.value && latInput.value !== "0") lat = latInput.value; |
| if (lonInput && lonInput.value && lonInput.value !== "0") lon = lonInput.value; |
| |
| const banner = document.getElementById("alert-banner-container"); |
| let alertText = "No active alert."; |
| if (banner) { |
| alertText = banner.innerText.trim(); |
| } |
| |
| const message = `SENTINEL EMERGENCY: Visually Impaired user needs assistance. Last Alert: ${alertText}. GPS Location: https://maps.google.com/?q=${lat},${lon}`; |
| |
| console.log("SOS triggered. Message payload:", message); |
| |
| try { |
| navigator.clipboard.writeText(message); |
| console.log("SOS message copied to clipboard."); |
| } catch (err) { |
| console.error("Failed to copy SOS message to clipboard:", err); |
| } |
| |
| const smsUrl = `sms:?body=${encodeURIComponent(message)}`; |
| window.open(smsUrl, '_blank'); |
| }); |
| } |
| |
| function initPipelineIndicator() { |
| const container = document.getElementById("pipeline-indicator"); |
| if (!container) { |
| setTimeout(initPipelineIndicator, 500); |
| return; |
| } |
| |
| const style = document.createElement("style"); |
| style.innerHTML = ` |
| .pipeline-row { |
| display: flex; |
| justify-content: space-around; |
| align-items: center; |
| background: rgba(15, 23, 42, 0.4); |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| border-radius: 8px; |
| padding: 10px; |
| margin: 10px 0; |
| } |
| .pipeline-step { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 4px; |
| color: #64748b; |
| font-family: monospace; |
| font-size: 10px; |
| transition: all 0.3s ease; |
| } |
| .pipeline-dot { |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| background: #334155; |
| box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); |
| transition: all 0.3s ease; |
| } |
| .pipeline-step.active { |
| color: #38bdf8; |
| text-shadow: 0 0 8px rgba(56, 189, 248, 0.5); |
| } |
| .pipeline-step.active .pipeline-dot { |
| background: #38bdf8; |
| box-shadow: 0 0 12px #38bdf8, inset 0 1px 2px rgba(255,255,255,0.8); |
| animation: pulse 1s infinite alternate; |
| } |
| @keyframes pulse { |
| 0% { transform: scale(1); opacity: 0.8; } |
| 100% { transform: scale(1.2); opacity: 1; } |
| } |
| `; |
| document.head.appendChild(style); |
| |
| container.innerHTML = ` |
| <div class="pipeline-row"> |
| <div class="pipeline-step" id="step-frame"> |
| <div class="pipeline-dot"></div> |
| <span>Frame</span> |
| </div> |
| <div style="color: #334155; font-size: 12px;">→</div> |
| <div class="pipeline-step" id="step-yolo"> |
| <div class="pipeline-dot"></div> |
| <span>YOLO</span> |
| </div> |
| <div style="color: #334155; font-size: 12px;">→</div> |
| <div class="pipeline-step" id="step-pose"> |
| <div class="pipeline-dot"></div> |
| <span>Pose</span> |
| </div> |
| <div style="color: #334155; font-size: 12px;">→</div> |
| <div class="pipeline-step" id="step-vlm"> |
| <div class="pipeline-dot"></div> |
| <span>VLM</span> |
| </div> |
| <div style="color: #334155; font-size: 12px;">→</div> |
| <div class="pipeline-step" id="step-tts"> |
| <div class="pipeline-dot"></div> |
| <span>TTS</span> |
| </div> |
| </div> |
| `; |
| console.log("Sentinel pipeline indicator initialized."); |
| bindPipelineObserver(); |
| } |
| |
| function bindPipelineObserver() { |
| const container = document.getElementById("pipeline-stage"); |
| if (!container) return; |
| |
| const textarea = container.querySelector("textarea") || container.querySelector("input"); |
| if (!textarea) return; |
| |
| function updateStage(stage) { |
| document.querySelectorAll(".pipeline-step").forEach(step => step.classList.remove("active")); |
| |
| if (stage === "frame") { |
| document.getElementById("step-frame")?.classList.add("active"); |
| } else if (stage === "yolo") { |
| document.getElementById("step-frame")?.classList.add("active"); |
| document.getElementById("step-yolo")?.classList.add("active"); |
| } else if (stage === "pose") { |
| document.getElementById("step-frame")?.classList.add("active"); |
| document.getElementById("step-yolo")?.classList.add("active"); |
| document.getElementById("step-pose")?.classList.add("active"); |
| } else if (stage === "vlm") { |
| document.getElementById("step-frame")?.classList.add("active"); |
| document.getElementById("step-yolo")?.classList.add("active"); |
| document.getElementById("step-pose")?.classList.add("active"); |
| document.getElementById("step-vlm")?.classList.add("active"); |
| } else if (stage === "tts") { |
| document.getElementById("step-frame")?.classList.add("active"); |
| document.getElementById("step-yolo")?.classList.add("active"); |
| document.getElementById("step-pose")?.classList.add("active"); |
| document.getElementById("step-vlm")?.classList.add("active"); |
| document.getElementById("step-tts")?.classList.add("active"); |
| } |
| } |
| |
| const observer = new MutationObserver(() => { |
| updateStage(textarea.value); |
| }); |
| observer.observe(textarea, { attributes: true, attributeFilter: ['value'] }); |
| |
| let lastVal = ""; |
| setInterval(() => { |
| const val = textarea.value; |
| if (val !== lastVal) { |
| lastVal = val; |
| updateStage(val); |
| } |
| }, 250); |
| } |
| |
| // Attach to the Gradio Activate Button once it is loaded in the DOM |
| function bindActivationButton() { |
| // Look for the primary activation button |
| const button = document.getElementById("activate-btn"); |
| if (button) { |
| button.addEventListener("click", () => { |
| if (!isActivated) { |
| button.innerHTML = "🟥 DEACTIVATE"; |
| button.style.background = "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)"; |
| startAllSensors(); |
| } else { |
| button.innerHTML = "🛡️ ACTIVATE SENTINEL"; |
| button.style.background = "linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)"; |
| stopAllSensors(); |
| } |
| }); |
| console.log("Sentinel activation button binding successful."); |
| } else { |
| // If layout hasn't rendered yet, check again shortly |
| setTimeout(bindActivationButton, 500); |
| } |
| } |
| |
| // Run binding hooks |
| if (document.readyState === "complete" || document.readyState === "interactive") { |
| bindActivationButton(); |
| bindDetectionDataObserver(); |
| bindAlertBannerObserver(); |
| bindVoiceQueryButton(); |
| bindSOSButton(); |
| initPipelineIndicator(); |
| setInterval(updateCostTicker, 250); |
| } else { |
| document.addEventListener("DOMContentLoaded", () => { |
| bindActivationButton(); |
| bindDetectionDataObserver(); |
| bindAlertBannerObserver(); |
| bindVoiceQueryButton(); |
| bindSOSButton(); |
| initPipelineIndicator(); |
| setInterval(updateCostTicker, 250); |
| }); |
| } |
| })(); |
| </script> |
| """ |
|
|