Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>ThermoScan AI | Industrial Temperature Monitoring</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<script> | |
tailwind.config = { | |
theme: { | |
extend: { | |
colors: { | |
industrial: { | |
50: '#f0f9ff', | |
100: '#e0f2fe', | |
200: '#bae6fd', | |
300: '#7dd3fc', | |
400: '#38bdf8', | |
500: '#0ea5e9', | |
600: '#0284c7', | |
700: '#0369a1', | |
800: '#075985', | |
900: '#0c4a6e', | |
}, | |
danger: { | |
500: '#ef4444', | |
600: '#dc2626', | |
}, | |
warning: { | |
500: '#f59e0b', | |
600: '#d97706', | |
}, | |
success: { | |
500: '#10b981', | |
600: '#059669', | |
} | |
} | |
} | |
} | |
} | |
</script> | |
<style> | |
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;600;700&display=swap'); | |
body { | |
font-family: 'Roboto Mono', monospace; | |
background: linear-gradient(135deg, #1a2a3a 0%, #0f172a 100%); | |
color: #e2e8f0; | |
min-height: 100vh; | |
} | |
.dashboard-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
gap: 1.5rem; | |
} | |
.industrial-card { | |
background: rgba(15, 23, 42, 0.7); | |
border: 1px solid rgba(56, 189, 248, 0.2); | |
border-radius: 0.75rem; | |
backdrop-filter: blur(10px); | |
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); | |
transition: all 0.3s ease; | |
} | |
.industrial-card:hover { | |
transform: translateY(-5px); | |
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3); | |
} | |
.camera-feed { | |
border: 2px dashed rgba(56, 189, 248, 0.5); | |
border-radius: 0.5rem; | |
background: rgba(15, 23, 42, 0.5); | |
} | |
.temperature-display { | |
font-size: 5rem; | |
font-weight: 700; | |
text-shadow: 0 0 10px rgba(56, 189, 248, 0.7); | |
transition: all 0.5s ease; | |
} | |
.status-indicator { | |
width: 12px; | |
height: 12px; | |
border-radius: 50%; | |
display: inline-block; | |
margin-right: 8px; | |
} | |
.status-normal { background-color: #10b981; } | |
.status-warning { background-color: #f59e0b; } | |
.status-danger { background-color: #ef4444; } | |
.history-item { | |
border-left: 3px solid #38bdf8; | |
transition: all 0.2s ease; | |
} | |
.history-item:hover { | |
background: rgba(56, 189, 248, 0.1); | |
transform: translateX(5px); | |
} | |
.gauge { | |
position: relative; | |
width: 200px; | |
height: 200px; | |
} | |
.gauge-circle { | |
fill: none; | |
stroke: rgba(30, 41, 59, 0.8); | |
stroke-width: 10; | |
} | |
.gauge-progress { | |
fill: none; | |
stroke: #38bdf8; | |
stroke-width: 10; | |
stroke-linecap: round; | |
transform: rotate(-90deg); | |
transform-origin: 50% 50%; | |
transition: stroke-dasharray 0.5s ease; | |
} | |
.pulse { | |
animation: pulse 2s infinite; | |
} | |
@keyframes pulse { | |
0% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.7); } | |
70% { box-shadow: 0 0 0 10px rgba(56, 189, 248, 0); } | |
100% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); } | |
} | |
.glow { | |
text-shadow: 0 0 10px rgba(56, 189, 248, 0.7); | |
} | |
/* Modal styles */ | |
.modal-overlay { | |
position: fixed; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background: rgba(15, 23, 42, 0.9); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
z-index: 1000; | |
} | |
.modal-content { | |
background: #1e293b; | |
border-radius: 0.5rem; | |
padding: 1.5rem; | |
width: 90%; | |
max-width: 500px; | |
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); | |
} | |
.modal-actions { | |
display: flex; | |
justify-content: flex-end; | |
gap: 0.75rem; | |
margin-top: 1rem; | |
} | |
</style> | |
</head> | |
<body class="min-h-screen p-4 md:p-8"> | |
<div class="max-w-7xl mx-auto"> | |
<!-- Header --> | |
<header class="flex flex-col md:flex-row justify-between items-center mb-8 md:mb-12"> | |
<div class="flex items-center mb-4 md:mb-0"> | |
<div class="bg-industrial-600 p-3 rounded-lg mr-4"> | |
<i class="fas fa-industry text-3xl text-industrial-200"></i> | |
</div> | |
<div> | |
<h1 class="text-2xl md:text-3xl font-bold text-white">ThermoScan<span class="text-industrial-400">AI</span></h1> | |
<p class="text-industrial-300 text-sm">Industrial Machine Temperature Monitoring</p> | |
</div> | |
</div> | |
<div class="flex items-center space-x-4"> | |
<div class="hidden md:block"> | |
<div class="flex items-center"> | |
<span class="status-indicator status-normal"></span> | |
<span class="text-industrial-300">System Status: <span class="text-success-500 font-medium">Operational</span></span> | |
</div> | |
<div class="text-xs text-industrial-400 mt-1">Connected to Gemini 1.5 Flash API</div> | |
</div> | |
<button id="settingsBtn" class="bg-industrial-600 hover:bg-industrial-500 text-white px-4 py-2 rounded-lg transition flex items-center"> | |
<i class="fas fa-cog mr-2"></i> Settings | |
</button> | |
</div> | |
</header> | |
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> | |
<!-- Main Camera Feed --> | |
<div class="lg:col-span-2"> | |
<div class="industrial-card h-full"> | |
<div class="p-4 border-b border-industrial-700 flex justify-between items-center"> | |
<h2 class="text-xl font-bold text-white">Machine Camera Feed</h2> | |
<div class="flex space-x-2"> | |
<button id="captureBtn" class="bg-industrial-500 hover:bg-industrial-400 text-white px-3 py-1 rounded text-sm flex items-center"> | |
<i class="fas fa-camera mr-1"></i> Capture & Analyze | |
</button> | |
</div> | |
</div> | |
<div class="p-4"> | |
<div class="camera-feed h-96 flex items-center justify-center relative"> | |
<video id="cameraFeed" class="w-full h-full object-contain" autoplay playsinline></video> | |
<canvas id="captureCanvas" class="hidden"></canvas> | |
</div> | |
<div class="mt-4 text-center text-industrial-300 text-sm"> | |
<i class="fas fa-microchip mr-1"></i> Using Gemini 2.5 Flash OCR | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Temperature Dashboard --> | |
<div class="industrial-card"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">Temperature Dashboard</h2> | |
</div> | |
<div class="p-4"> | |
<div class="flex flex-col items-center mb-6"> | |
<div class="text-industrial-300 mb-2">Current Temperature</div> | |
<div id="currentTemp" class="temperature-display text-industrial-200">--°C</div> | |
<div id="tempStatus" class="mt-2 px-3 py-1 rounded-full bg-industrial-700 text-industrial-300 text-sm"> | |
<span class="status-indicator"></span> No data | |
</div> | |
</div> | |
<div class="grid grid-cols-2 gap-4 mb-6"> | |
<div class="industrial-card bg-industrial-800 p-4 rounded-lg"> | |
<div class="text-industrial-400 text-sm mb-1">Maximum</div> | |
<div id="maxTemp" class="text-2xl font-bold text-white">--°C</div> | |
</div> | |
<div class="industrial-card bg-industrial-800 p-4 rounded-lg"> | |
<div class="text-industrial-400 text-sm mb-1">Minimum</div> | |
<div id="minTemp" class="text-2xl font-bold text-white">--°C</div> | |
</div> | |
</div> | |
<div class="flex justify-center mb-4"> | |
<div class="gauge"> | |
<svg width="200" height="200" viewBox="0 0 200 200"> | |
<circle class="gauge-circle" cx="100" cy="100" r="90" /> | |
<circle id="gaugeProgress" class="gauge-progress" cx="100" cy="100" r="90" | |
stroke-dasharray="0 565" /> | |
</svg> | |
<div class="absolute inset-0 flex items-center justify-center"> | |
<div id="gaugeValue" class="text-3xl font-bold text-white">--</div> | |
</div> | |
</div> | |
</div> | |
<div class="text-center text-industrial-400 text-sm"> | |
<i class="fas fa-thermometer-half mr-1"></i> Normal Range: 20°C - 35°C | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- History Log --> | |
<div class="industrial-card mb-8"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">Temperature History</h2> | |
</div> | |
<div class="p-4"> | |
<div class="flex justify-between mb-4"> | |
<div class="text-industrial-300"> | |
Last 20 readings | |
</div> | |
<div class="flex space-x-2"> | |
<button id="exportDataBtn" class="bg-industrial-700 hover:bg-industrial-600 text-white px-3 py-1 rounded text-sm"> | |
<i class="fas fa-download mr-1"></i> Export Data | |
</button> | |
<button id="stopRecordingBtn" class="bg-danger-600 hover:bg-danger-500 text-white px-3 py-1 rounded text-sm ml-2 hidden"> | |
<i class="fas fa-stop mr-1"></i> Stop Recording | |
</button> | |
</div> | |
</div> | |
<div class="overflow-x-auto"> | |
<table class="min-w-full divide-y divide-industrial-700"> | |
<thead> | |
<tr> | |
<th class="px-4 py-3 text-left text-xs font-medium text-industrial-400 uppercase tracking-wider">Timestamp</th> | |
<th class="px-4 py-3 text-left text-xs font-medium text-industrial-400 uppercase tracking-wider">Temperature</th> | |
<th class="px-4 py-3 text-left text-xs font-medium text-industrial-400 uppercase tracking-wider">Status</th> | |
<th class="px-4 py-3 text-left text-xs font-medium text-industrial-400 uppercase tracking-wider">Actions</th> | |
</tr> | |
</thead> | |
<tbody id="historyBody" class="divide-y divide-industrial-800"> | |
<!-- History items will be added here dynamically --> | |
<tr> | |
<td colspan="4" class="px-4 py-8 text-center text-industrial-500"> | |
No temperature data recorded yet | |
</td> | |
</tr> | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</div> | |
<!-- Real-time Chart --> | |
<div class="industrial-card mb-8"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">Temperature Trend</h2> | |
</div> | |
<div class="p-4"> | |
<canvas id="liveChart" height="300"></canvas> | |
</div> | |
</div> | |
<!-- System Status --> | |
<div class="dashboard-grid mb-8"> | |
<div class="industrial-card"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">OCR Status</h2> | |
</div> | |
<div class="p-4"> | |
<div class="flex items-center mb-4"> | |
<div class="mr-4"> | |
<div class="bg-industrial-700 rounded-full p-3"> | |
<i class="fas fa-eye text-industrial-300 text-2xl"></i> | |
</div> | |
</div> | |
<div> | |
<div class="text-industrial-300">Gemini OCR Engine</div> | |
<div class="text-white font-bold text-lg">Operational</div> | |
</div> | |
</div> | |
<div class="bg-industrial-800 rounded-lg p-3"> | |
<div class="text-industrial-400 text-sm mb-1">Last OCR Result</div> | |
<div id="lastOcrResult" class="text-industrial-200 font-mono">Waiting for first capture...</div> | |
</div> | |
</div> | |
</div> | |
<div class="industrial-card"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">System Alerts</h2> | |
</div> | |
<div class="p-4"> | |
<div class="flex items-center mb-4"> | |
<div class="mr-4"> | |
<div class="bg-industrial-700 rounded-full p-3"> | |
<i class="fas fa-bell text-industrial-300 text-2xl"></i> | |
</div> | |
</div> | |
<div> | |
<div class="text-industrial-300">Alert Status</div> | |
<div class="text-white font-bold text-lg">No Active Alerts</div> | |
</div> | |
</div> | |
<div class="bg-industrial-800 rounded-lg p-3"> | |
<div class="text-industrial-400 text-sm mb-1">Notification Settings</div> | |
<div class="text-industrial-200">Email alerts enabled for temperatures above 35°C</div> | |
</div> | |
</div> | |
</div> | |
<div class="industrial-card"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">API Status</h2> | |
</div> | |
<div class="p-4"> | |
<div class="flex items-center mb-4"> | |
<div class="mr-4"> | |
<div class="bg-industrial-700 rounded-full p-3"> | |
<i class="fas fa-plug text-industrial-300 text-2xl"></i> | |
</div> | |
</div> | |
<div> | |
<div class="text-industrial-300">Gemini API</div> | |
<div class="text-white font-bold text-lg">Connected</div> | |
</div> | |
</div> | |
<div class="bg-industrial-800 rounded-lg p-3"> | |
<div class="text-industrial-400 text-sm mb-1">Rate Limit Status</div> | |
<div class="text-industrial-200">10 requests/min available (1 used)</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Debug Console --> | |
<div class="industrial-card mb-8"> | |
<div class="p-4 border-b border-industrial-700"> | |
<h2 class="text-xl font-bold text-white">Debug Console</h2> | |
</div> | |
<div class="p-4"> | |
<div id="debugConsole" class="bg-black text-green-400 font-mono text-sm p-4 rounded h-64 overflow-y-auto"></div> | |
</div> | |
</div> | |
<!-- Footer --> | |
<footer class="text-center text-industrial-500 text-sm pt-6 border-t border-industrial-800"> | |
<p>ThermoScanAI - Industrial Machine Temperature Monitoring System | Using Gemini 1.5 Flash OCR</p> | |
<p class="mt-2">© 2023 Industrial AI Solutions. All rights reserved.</p> | |
</footer> | |
</div> | |
<script> | |
// DOM Elements | |
const cameraFeed = document.getElementById('cameraFeed'); | |
const cameraPlaceholder = document.getElementById('cameraPlaceholder'); | |
const captureCanvas = document.getElementById('captureCanvas'); | |
const startBtn = document.getElementById('startBtn'); | |
const stopBtn = document.getElementById('stopBtn'); | |
const countdownEl = document.getElementById('countdown'); | |
const currentTempEl = document.getElementById('currentTemp'); | |
const maxTempEl = document.getElementById('maxTemp'); | |
const minTempEl = document.getElementById('minTemp'); | |
const tempStatusEl = document.getElementById('tempStatus'); | |
const historyBody = document.getElementById('historyBody'); | |
const gaugeProgress = document.getElementById('gaugeProgress'); | |
const gaugeValue = document.getElementById('gaugeValue'); | |
const lastOcrResult = document.getElementById('lastOcrResult'); | |
// API Key Management | |
let apiKey = localStorage.getItem('geminiApiKey') || ''; | |
const apiKeyModal = document.createElement('div'); | |
apiKeyModal.className = 'fixed inset-0 bg-industrial-900 bg-opacity-90 flex items-center justify-center z-50 hidden'; | |
apiKeyModal.innerHTML = ` | |
<div class="bg-industrial-800 rounded-lg p-6 max-w-md w-full"> | |
<h3 class="text-xl font-bold mb-4">Enter Gemini API Key</h3> | |
<input type="password" id="apiKeyInput" placeholder="Your Gemini API Key" | |
class="w-full bg-industrial-700 border border-industrial-600 rounded p-3 mb-4 text-white"> | |
<div class="flex justify-end space-x-3"> | |
<button id="cancelApiKey" class="px-4 py-2 rounded bg-industrial-600 hover:bg-industrial-500"> | |
Cancel | |
</button> | |
<button id="saveApiKey" class="px-4 py-2 rounded bg-industrial-500 hover:bg-industrial-400"> | |
Save Key | |
</button> | |
</div> | |
</div> | |
`; | |
document.body.appendChild(apiKeyModal); | |
// App State | |
let monitoringInterval; | |
let countdownInterval; | |
let countdown = 10; | |
let temperatureHistory = []; | |
let maxTemp = null; | |
let minTemp = null; | |
let stream = null; | |
let hasValidApiKey = false; | |
// Initialize the app | |
async function init() { | |
// Set up capture button | |
document.getElementById('captureBtn').addEventListener('click', captureAndProcess); | |
updateGauge(0); | |
// Initialize camera when user clicks capture for the first time | |
document.getElementById('captureBtn').addEventListener('click', async function firstCapture() { | |
try { | |
if (!stream) { | |
stream = await navigator.mediaDevices.getUserMedia({ | |
video: { | |
facingMode: 'environment', | |
width: { ideal: 1280 }, | |
height: { ideal: 720 } | |
} | |
}); | |
cameraFeed.srcObject = stream; | |
} | |
} catch (err) { | |
console.error("Error accessing camera:", err); | |
cameraFeed.parentElement.innerHTML = ` | |
<div class="text-center text-industrial-300 p-4"> | |
<i class="fas fa-video-slash text-4xl mb-2"></i> | |
<p>Could not access camera. Please check permissions.</p> | |
<button onclick="window.location.reload()" class="mt-2 bg-industrial-600 hover:bg-industrial-500 text-white px-4 py-2 rounded-lg"> | |
Try Again | |
</button> | |
</div> | |
`; | |
} | |
// Remove this event listener after first run | |
document.getElementById('captureBtn').removeEventListener('click', firstCapture); | |
}, { once: true }); | |
// API Key management | |
document.getElementById('settingsBtn').addEventListener('click', () => { | |
apiKeyModal.classList.remove('hidden'); | |
document.getElementById('apiKeyInput').value = apiKey; | |
}); | |
document.getElementById('saveApiKey').addEventListener('click', () => { | |
apiKey = document.getElementById('apiKeyInput').value.trim(); | |
localStorage.setItem('geminiApiKey', apiKey); | |
apiKeyModal.classList.add('hidden'); | |
hasValidApiKey = apiKey.length > 0; | |
}); | |
document.getElementById('cancelApiKey').addEventListener('click', () => { | |
apiKeyModal.classList.add('hidden'); | |
}); | |
hasValidApiKey = apiKey.length > 0; | |
} | |
// Update countdown display | |
function updateCountdown() { | |
countdownEl.textContent = countdown; | |
if (countdown <= 0) { | |
countdown = 10; | |
} | |
countdownInterval = setTimeout(() => { | |
countdown--; | |
updateCountdown(); | |
}, 1000); | |
} | |
// Capture image and process | |
function captureAndProcess() { | |
if (!stream) return; | |
// Capture frame | |
const context = captureCanvas.getContext('2d'); | |
captureCanvas.width = cameraFeed.videoWidth; | |
captureCanvas.height = cameraFeed.videoHeight; | |
context.drawImage(cameraFeed, 0, 0, captureCanvas.width, captureCanvas.height); | |
// Convert to base64 for API | |
const imageData = captureCanvas.toDataURL('image/jpeg').split(',')[1]; | |
// Process with Gemini | |
processWithGemini(imageData); | |
} | |
// Start periodic capturing | |
function startPeriodicCapture(intervalSeconds = 10) { | |
logDebug(`Starting periodic capture every ${intervalSeconds} seconds`); | |
return setInterval(() => { | |
logDebug("Auto-capturing image..."); | |
captureAndProcess(); | |
}, intervalSeconds * 1000); | |
} | |
// Stop periodic capturing | |
function stopPeriodicCapture(intervalId) { | |
clearInterval(intervalId); | |
logDebug("Stopped periodic capture"); | |
} | |
// Debug logging function | |
function logDebug(message) { | |
const debugConsole = document.getElementById('debugConsole'); | |
const timestamp = new Date().toLocaleTimeString(); | |
const logEntry = document.createElement('div'); | |
logEntry.innerHTML = `[${timestamp}] ${message}`; | |
debugConsole.appendChild(logEntry); | |
debugConsole.scrollTop = debugConsole.scrollHeight; | |
// Also log to browser console | |
console.log(`[ThermoScan] ${message}`); | |
// Keep only last 100 messages | |
if (debugConsole.children.length > 100) { | |
debugConsole.removeChild(debugConsole.children[0]); | |
} | |
} | |
// Process with Gemini API | |
async function processWithGemini(imageData) { | |
if (!hasValidApiKey) { | |
const msg = "API Key not configured"; | |
lastOcrResult.textContent = msg; | |
logDebug(msg); | |
currentTempEl.textContent = "--°C"; | |
gaugeValue.textContent = "--"; | |
return; | |
} | |
lastOcrResult.textContent = "Processing image..."; | |
logDebug("Starting image processing with Gemini API"); | |
logDebug(`Image data size: ${Math.round(imageData.length / 1024)}KB`); | |
try { | |
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`; | |
logDebug(`Sending request to: ${apiUrl}`); | |
const requestBody = { | |
contents: [{ | |
parts: [{ | |
text: "Extract the numerical temperature value from this image. Only return the number, nothing else." | |
}, { | |
inlineData: { | |
mimeType: "image/jpeg", | |
data: imageData | |
} | |
}] | |
}] | |
}; | |
logDebug("Request payload prepared"); | |
logDebug(`Prompt: "${requestBody.contents[0].parts[0].text}"`); | |
const response = await fetch(apiUrl, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
contents: [{ | |
parts: [{ | |
text: "Analyze this image of an industrial machine temperature display. Extract only the numerical temperature value. Return just the number with no additional text or symbols." | |
}, { | |
inlineData: { | |
mimeType: "image/jpeg", | |
data: imageData | |
} | |
}] | |
}], | |
generationConfig: { | |
temperature: 0.1, | |
topP: 0.1, | |
topK: 1 | |
} | |
}) | |
}); | |
const data = await response.json(); | |
logDebug(`API response status: ${response.status}`); | |
logDebug(`Full response: ${JSON.stringify(data, null, 2)}`); | |
if (data.candidates && data.candidates[0].content.parts[0].text) { | |
const result = data.candidates[0].content.parts[0].text; | |
logDebug(`Raw API response text: "${result}"`); | |
// More robust temperature parsing | |
let temperature; | |
const tempMatch = result.match(/-?\d+(\.\d+)?/); | |
if (tempMatch) { | |
temperature = parseFloat(tempMatch[0]); | |
// Handle cases where OCR might return values like "35 C" or "35C" | |
if (isNaN(temperature)) { | |
const cleaned = result.replace(/[^\d.-]/g, ''); | |
temperature = parseFloat(cleaned); | |
} | |
const msg = `Detected temperature: ${temperature}°C`; | |
lastOcrResult.textContent = msg; | |
logDebug(msg); | |
updateTemperature(temperature); | |
} else { | |
lastOcrResult.textContent = "No temperature detected"; | |
currentTempEl.textContent = "--°C"; | |
gaugeValue.textContent = "--"; | |
} | |
} else { | |
lastOcrResult.textContent = "Failed to process image"; | |
currentTempEl.textContent = "--°C"; | |
gaugeValue.textContent = "--"; | |
} | |
} catch (error) { | |
console.error("Gemini API error:", error); | |
const msg = `API Error: ${error.message}`; | |
lastOcrResult.textContent = msg; | |
logDebug(msg); | |
currentTempEl.textContent = "--°C"; | |
gaugeValue.textContent = "--"; | |
} | |
} | |
// Update temperature display and history | |
let lastWebhookTimestamp = 0; // Stores last webhook call time | |
function updateTemperature(temp) { | |
currentTempEl.textContent = `${temp}°C`; | |
updateGauge(temp); | |
updateStatus(temp); | |
temperatureHistory.push({ | |
temp: temp, | |
timestamp: new Date().toLocaleTimeString(), | |
status: getStatus(temp) | |
}); | |
if (temperatureHistory.length > 20) { | |
temperatureHistory.shift(); | |
} | |
if (maxTemp === null || temp > maxTemp) { | |
maxTemp = temp; | |
maxTempEl.textContent = `${maxTemp}°C`; | |
} | |
if (minTemp === null || temp < minTemp) { | |
minTemp = temp; | |
minTempEl.textContent = `${minTemp}°C`; | |
} | |
updateHistoryTable(); | |
updateLiveChart(temp, new Date().toLocaleTimeString()); | |
// Webhook Integration with improved reliability | |
const now = Date.now(); | |
const criticalThreshold = 50; // Could be loaded from settings | |
const webhookCooldown = 30000; // 30 seconds | |
if (temp > criticalThreshold && now - lastWebhookTimestamp > webhookCooldown) { | |
lastWebhookTimestamp = now; | |
logDebug(`Triggering webhook for critical temperature: ${temp}°C`); | |
const sendWebhook = (attempt = 1) => { | |
const webhookUrl = 'https://n8n-1r4e.onrender.com/webhook-test/2af1157d-c7d7-4e57-86c8-577c5a005f40'; | |
const payload = { | |
temperature: temp, | |
timestamp: new Date().toISOString(), | |
status: getStatus(temp), | |
message: `⚠️ Critical temperature (${temp}°C) detected. Immediate action required.`, | |
deviceInfo: navigator.userAgent, | |
attempt: attempt | |
}; | |
logDebug(`Sending webhook attempt ${attempt} to: ${webhookUrl}`); | |
logDebug(`Payload: ${JSON.stringify(payload)}`); | |
fetch(webhookUrl, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'X-ThermoScan-Signature': 'your-secret-key-here' // Add if your webhook requires auth | |
}, | |
body: JSON.stringify(payload) | |
}) | |
.then(async res => { | |
if (!res.ok) { | |
const errorText = await res.text(); | |
throw new Error(`HTTP ${res.status}: ${errorText}`); | |
} | |
return res.json(); | |
}) | |
.then(data => { | |
logDebug(`Webhook successful. Response: ${JSON.stringify(data)}`); | |
showAlert('Critical temperature alert sent', 'success'); | |
}) | |
.catch(err => { | |
logDebug(`Webhook attempt ${attempt} failed: ${err.message}`); | |
if (attempt < 3) { | |
const retryDelay = 5000 * attempt; | |
logDebug(`Retrying in ${retryDelay/1000} seconds...`); | |
setTimeout(() => sendWebhook(attempt + 1), retryDelay); | |
} else { | |
logDebug('Max retry attempts reached'); | |
showAlert('Failed to send critical alert after 3 attempts', 'error'); | |
} | |
}); | |
}; | |
sendWebhook(); | |
} else if (temp > criticalThreshold) { | |
const timeLeft = Math.ceil((webhookCooldown - (now - lastWebhookTimestamp)) / 1000); | |
logDebug(`Webhook cooldown active. ${timeLeft}s remaining until next alert can be sent`); | |
} | |
} | |
// Update gauge display | |
function updateGauge(temp) { | |
// Normalize temperature to gauge range (0-50°C) | |
const percentage = Math.min(Math.max((temp / 50) * 100, 0), 100); | |
const dashValue = (565 * percentage) / 100; | |
gaugeProgress.style.strokeDasharray = `${dashValue} 565`; | |
gaugeValue.textContent = `${temp}°C`; | |
// Update gauge color based on temperature | |
if (temp > 35) { | |
gaugeProgress.style.stroke = '#ef4444'; | |
} else if (temp > 30) { | |
gaugeProgress.style.stroke = '#f59e0b'; | |
} else { | |
gaugeProgress.style.stroke = '#38bdf8'; | |
} | |
} | |
// Update temperature status | |
function updateStatus(temp) { | |
const statusIndicator = tempStatusEl.querySelector('.status-indicator'); | |
statusIndicator.className = 'status-indicator'; | |
if (temp > 35) { | |
tempStatusEl.innerHTML = '<span class="status-indicator status-danger"></span> CRITICAL TEMPERATURE'; | |
tempStatusEl.className = 'mt-2 px-3 py-1 rounded-full bg-danger-900 text-danger-200 text-sm'; | |
currentTempEl.classList.add('text-danger-500'); | |
currentTempEl.classList.remove('text-industrial-200', 'text-warning-500'); | |
} else if (temp > 30) { | |
tempStatusEl.innerHTML = '<span class="status-indicator status-warning"></span> HIGH TEMPERATURE'; | |
tempStatusEl.className = 'mt-2 px-3 py-1 rounded-full bg-warning-900 text-warning-200 text-sm'; | |
currentTempEl.classList.add('text-warning-500'); | |
currentTempEl.classList.remove('text-industrial-200', 'text-danger-500'); | |
} else { | |
tempStatusEl.innerHTML = '<span class="status-indicator status-normal"></span> NORMAL'; | |
tempStatusEl.className = 'mt-2 px-3 py-1 rounded-full bg-industrial-800 text-industrial-300 text-sm'; | |
currentTempEl.classList.add('text-industrial-200'); | |
currentTempEl.classList.remove('text-warning-500', 'text-danger-500'); | |
} | |
} | |
// Get status for history | |
function getStatus(temp) { | |
if (temp > 35) return 'critical'; | |
if (temp > 30) return 'warning'; | |
return 'normal'; | |
} | |
// Update history table | |
function updateHistoryTable() { | |
if (temperatureHistory.length === 0) { | |
historyBody.innerHTML = ` | |
<tr> | |
<td colspan="4" class="px-4 py-8 text-center text-industrial-500"> | |
No temperature data recorded yet | |
</td> | |
</tr> | |
`; | |
return; | |
} | |
let historyHTML = ''; | |
temperatureHistory.slice().reverse().forEach(reading => { | |
let statusClass = ''; | |
let statusText = ''; | |
switch(reading.status) { | |
case 'critical': | |
statusClass = 'text-danger-500'; | |
statusText = 'Critical'; | |
break; | |
case 'warning': | |
statusClass = 'text-warning-500'; | |
statusText = 'Warning'; | |
break; | |
default: | |
statusClass = 'text-success-500'; | |
statusText = 'Normal'; | |
} | |
historyHTML += ` | |
<tr class="history-item"> | |
<td class="px-4 py-3 whitespace-nowrap text-sm text-industrial-300">${reading.timestamp}</td> | |
<td class="px-4 py-3 whitespace-nowrap"> | |
<div class="text-lg font-bold ${reading.status === 'critical' ? 'text-danger-500' : reading.status === 'warning' ? 'text-warning-500' : 'text-industrial-200'}"> | |
${reading.temp}°C | |
</div> | |
</td> | |
<td class="px-4 py-3 whitespace-nowrap"> | |
<span class="${statusClass} font-medium">${statusText}</span> | |
</td> | |
<td class="px-4 py-3 whitespace-nowrap text-sm"> | |
<button class="text-industrial-400 hover:text-industrial-300 mr-2" onclick="showTemperatureChart()"> | |
<i class="fas fa-chart-line"></i> | |
</button> | |
<button class="text-industrial-400 hover:text-industrial-300"> | |
<i class="fas fa-info-circle"></i> | |
</button> | |
</td> | |
</tr> | |
`; | |
}); | |
historyBody.innerHTML = historyHTML; | |
} | |
// Export data as CSV | |
function exportData() { | |
if (temperatureHistory.length === 0) { | |
logDebug("No data to export"); | |
return; | |
} | |
let csvContent = "Timestamp,Temperature,Status\n"; | |
temperatureHistory.forEach(reading => { | |
csvContent += `${reading.timestamp},${reading.temp},${reading.status}\n`; | |
}); | |
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
const url = URL.createObjectURL(blob); | |
const link = document.createElement('a'); | |
link.setAttribute('href', url); | |
link.setAttribute('download', `thermoscan_data_${new Date().toISOString().slice(0,10)}.csv`); | |
link.style.visibility = 'hidden'; | |
document.body.appendChild(link); | |
link.click(); | |
document.body.removeChild(link); | |
logDebug("Data exported as CSV"); | |
} | |
// Chart functions | |
let temperatureChart = null; | |
function showTemperatureChart() { | |
const modal = document.getElementById('chartModal'); | |
modal.classList.remove('hidden'); | |
const ctx = document.getElementById('temperatureChart').getContext('2d'); | |
// Destroy previous chart if exists | |
if (temperatureChart) { | |
temperatureChart.destroy(); | |
} | |
// Prepare chart data | |
const labels = temperatureHistory.map(reading => reading.timestamp); | |
const data = temperatureHistory.map(reading => reading.temp); | |
const statusColors = temperatureHistory.map(reading => { | |
switch(reading.status) { | |
case 'critical': return '#ef4444'; | |
case 'warning': return '#f59e0b'; | |
default: return '#10b981'; | |
} | |
}); | |
temperatureChart = new Chart(ctx, { | |
type: 'line', | |
data: { | |
labels: labels, | |
datasets: [{ | |
label: 'Temperature (°C)', | |
data: data, | |
borderColor: '#38bdf8', | |
backgroundColor: 'rgba(56, 189, 248, 0.1)', | |
borderWidth: 2, | |
pointBackgroundColor: statusColors, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
tension: 0.1, | |
fill: true | |
}] | |
}, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
scales: { | |
y: { | |
beginAtZero: false, | |
grid: { | |
color: 'rgba(30, 41, 59, 0.5)' | |
}, | |
ticks: { | |
color: '#94a3b8' | |
} | |
}, | |
x: { | |
grid: { | |
color: 'rgba(30, 41, 59, 0.5)' | |
}, | |
ticks: { | |
color: '#94a3b8' | |
} | |
} | |
}, | |
plugins: { | |
legend: { | |
labels: { | |
color: '#e2e8f0' | |
} | |
}, | |
tooltip: { | |
backgroundColor: '#1e293b', | |
titleColor: '#e2e8f0', | |
bodyColor: '#e2e8f0', | |
borderColor: '#334155', | |
borderWidth: 1 | |
} | |
} | |
} | |
}); | |
} | |
function exportChartAsImage() { | |
if (!temperatureChart) return; | |
const link = document.createElement('a'); | |
link.download = `temperature_chart_${new Date().toISOString().slice(0,10)}.png`; | |
link.href = temperatureChart.toBase64Image(); | |
link.click(); | |
} | |
// Chart variables | |
let liveChart = null; | |
let chartData = { | |
labels: [], | |
datasets: [{ | |
label: 'Temperature (°C)', | |
data: [], | |
borderColor: '#38bdf8', | |
backgroundColor: 'rgba(56, 189, 248, 0.1)', | |
borderWidth: 2, | |
pointRadius: 3, | |
tension: 0.1, | |
fill: true | |
}] | |
}; | |
// Initialize live chart | |
function initLiveChart() { | |
const ctx = document.getElementById('liveChart').getContext('2d'); | |
liveChart = new Chart(ctx, { | |
type: 'line', | |
data: chartData, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
animation: { | |
duration: 1000, | |
easing: 'linear' | |
}, | |
scales: { | |
y: { | |
beginAtZero: false, | |
grid: { | |
color: 'rgba(30, 41, 59, 0.5)' | |
}, | |
ticks: { | |
color: '#94a3b8' | |
} | |
}, | |
x: { | |
grid: { | |
color: 'rgba(30, 41, 59, 0.5)' | |
}, | |
ticks: { | |
color: '#94a3b8', | |
maxRotation: 45, | |
minRotation: 45 | |
} | |
} | |
}, | |
plugins: { | |
legend: { | |
labels: { | |
color: '#e2e8f0' | |
} | |
}, | |
tooltip: { | |
backgroundColor: '#1e293b', | |
titleColor: '#e2e8f0', | |
bodyColor: '#e2e8f0', | |
borderColor: '#334155', | |
borderWidth: 1 | |
} | |
} | |
} | |
}); | |
} | |
// Update live chart with new data | |
function updateLiveChart(temp, timestamp) { | |
// Add new data point | |
chartData.labels.push(timestamp); | |
chartData.datasets[0].data.push(temp); | |
// Keep only last 20 points | |
if (chartData.labels.length > 20) { | |
chartData.labels.shift(); | |
chartData.datasets[0].data.shift(); | |
} | |
// Update chart | |
liveChart.update(); | |
} | |
// Initialize the app when DOM is loaded | |
document.addEventListener('DOMContentLoaded', () => { | |
initLiveChart(); | |
init(); | |
// Start/stop periodic capture | |
let captureInterval; | |
const captureBtn = document.getElementById('captureBtn'); | |
const stopRecordingBtn = document.getElementById('stopRecordingBtn'); | |
captureBtn.addEventListener('click', () => { | |
if (captureInterval) { | |
stopPeriodicCapture(captureInterval); | |
captureInterval = null; | |
captureBtn.innerHTML = '<i class="fas fa-camera mr-1"></i> Capture & Analyze'; | |
stopRecordingBtn.classList.add('hidden'); | |
} else { | |
captureInterval = startPeriodicCapture(10); | |
captureBtn.innerHTML = '<i class="fas fa-pause mr-1"></i> Pause Recording'; | |
stopRecordingBtn.classList.remove('hidden'); | |
} | |
}); | |
// Stop recording button | |
stopRecordingBtn.addEventListener('click', () => { | |
if (captureInterval) { | |
stopPeriodicCapture(captureInterval); | |
captureInterval = null; | |
captureBtn.innerHTML = '<i class="fas fa-camera mr-1"></i> Capture & Analyze'; | |
stopRecordingBtn.classList.add('hidden'); | |
} | |
}); | |
// Export data button | |
document.getElementById('exportDataBtn').addEventListener('click', exportData); | |
// Chart modal events | |
document.getElementById('closeChartModal').addEventListener('click', () => { | |
document.getElementById('chartModal').classList.add('hidden'); | |
}); | |
document.getElementById('exportChartBtn').addEventListener('click', exportChartAsImage); | |
}); | |
</script> | |
<!-- Chart Modal --> | |
<div id="chartModal" class="modal-overlay hidden"> | |
<div class="modal-content"> | |
<div class="flex justify-between items-center mb-4"> | |
<h3 class="text-xl font-bold">Temperature History Chart</h3> | |
<button id="closeChartModal" class="text-industrial-400 hover:text-industrial-300"> | |
<i class="fas fa-times"></i> | |
</button> | |
</div> | |
<div class="bg-industrial-800 p-4 rounded-lg"> | |
<canvas id="temperatureChart" height="300"></canvas> | |
</div> | |
<div class="modal-actions"> | |
<button id="exportChartBtn" class="bg-industrial-600 hover:bg-industrial-500 text-white px-4 py-2 rounded-lg"> | |
<i class="fas fa-download mr-2"></i> Export as Image | |
</button> | |
</div> | |
</div> | |
</div> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=pksaheb/temperature-monitoring" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
</html> |