678 lines
31 KiB
HTML
678 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="th">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>IMOU Vision - AC Ribbon Monitor</title>
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css">
|
|
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
|
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
fontFamily: {
|
|
sans: ['Inter', 'sans-serif'],
|
|
},
|
|
colors: {
|
|
magic: {
|
|
bg: '#030712',
|
|
card: '#111827',
|
|
border: '#1f2937'
|
|
}
|
|
},
|
|
animation: {
|
|
'ribbon-flutter': 'flutter 0.3s ease-in-out infinite alternate',
|
|
'ribbon-still': 'none',
|
|
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
},
|
|
keyframes: {
|
|
flutter: {
|
|
'0%': { transform: 'rotate(5deg) skewX(2deg)' },
|
|
'100%': { transform: 'rotate(25deg) skewX(-5deg)' },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
body {
|
|
background-color: #030712;
|
|
color: #f3f4f6;
|
|
background-image:
|
|
radial-gradient(at 0% 0%, hsla(253, 16%, 7%, 1) 0, transparent 50%),
|
|
radial-gradient(at 50% 0%, hsla(225, 39%, 30%, 0.2) 0, transparent 50%),
|
|
radial-gradient(at 100% 0%, hsla(339, 49%, 30%, 0.2) 0, transparent 50%);
|
|
background-attachment: fixed;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Magic UI Glassmorphism Card */
|
|
.glass-card {
|
|
background: rgba(17, 24, 39, 0.7);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Camera Feed Container */
|
|
.cam-feed {
|
|
background: linear-gradient(to bottom, #1f2937, #111827);
|
|
position: relative;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Customizing DataTable for Dark Theme */
|
|
.dataTables_wrapper .dataTables_length,
|
|
.dataTables_wrapper .dataTables_filter,
|
|
.dataTables_wrapper .dataTables_info,
|
|
.dataTables_wrapper .dataTables_processing,
|
|
.dataTables_wrapper .dataTables_paginate {
|
|
color: #9ca3af !important;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
table.dataTable tbody tr {
|
|
background-color: transparent !important;
|
|
}
|
|
|
|
table.dataTable.row-border tbody th,
|
|
table.dataTable.row-border tbody td,
|
|
table.dataTable.display tbody th,
|
|
table.dataTable.display tbody td {
|
|
border-top: 1px solid #374151;
|
|
color: #d1d5db;
|
|
}
|
|
|
|
table.dataTable thead th,
|
|
table.dataTable thead td {
|
|
border-bottom: 1px solid #4b5563;
|
|
color: #e5e7eb;
|
|
}
|
|
|
|
.dataTables_wrapper .dataTables_paginate .paginate_button {
|
|
color: #9ca3af !important;
|
|
}
|
|
|
|
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
|
|
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
|
|
background: #374151 !important;
|
|
color: white !important;
|
|
border: 1px solid #4b5563 !important;
|
|
}
|
|
|
|
.dataTables_wrapper .dataTables_filter input {
|
|
border: 1px solid #4b5563;
|
|
background: #1f2937;
|
|
color: white;
|
|
border-radius: 0.375rem;
|
|
padding: 0.25rem 0.5rem;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.dataTables_wrapper .dataTables_length select {
|
|
border: 1px solid #4b5563;
|
|
background: #1f2937;
|
|
color: white;
|
|
border-radius: 0.375rem;
|
|
padding: 0.25rem;
|
|
}
|
|
|
|
/* Ribbon Base Styling */
|
|
.ribbon {
|
|
width: 8px;
|
|
height: 60px;
|
|
background: linear-gradient(to bottom, #ef4444, #dc2626);
|
|
transform-origin: top center;
|
|
border-radius: 0 0 4px 4px;
|
|
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
|
|
transition: all 0.5s ease;
|
|
}
|
|
|
|
/* Toggle Switch Styles */
|
|
.toggle-checkbox:checked {
|
|
right: 0;
|
|
border-color: #10b981;
|
|
}
|
|
|
|
.toggle-checkbox:checked+.toggle-label {
|
|
background-color: #10b981;
|
|
}
|
|
|
|
.toggle-checkbox {
|
|
right: 4px;
|
|
z-index: 1;
|
|
border-color: #4b5563;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.toggle-label {
|
|
background-color: #374151;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
#mjpeg-stream {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#overlay-canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: crosshair;
|
|
z-index: 20;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="p-4 md:p-8 font-sans antialiased">
|
|
|
|
<div class="max-w-7xl mx-auto space-y-6">
|
|
|
|
<!-- Header -->
|
|
<header class="flex flex-col md:flex-row justify-between items-start md:items-center glass-card p-6 rounded-2xl">
|
|
<div>
|
|
<h1 class="text-2xl md:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-emerald-400 flex items-center gap-2">
|
|
<i class="ph ph-video-camera text-blue-400"></i>
|
|
IMOU Vision Smart Monitor
|
|
</h1>
|
|
<p class="text-gray-400 text-sm mt-1">AC Flow Detection System via IP Camera Ribbon Tracking</p>
|
|
</div>
|
|
<div class="mt-4 md:mt-0 flex items-center gap-4">
|
|
<button onclick="openSettings()" class="bg-gray-800/80 hover:bg-gray-700 text-gray-200 px-4 py-2 rounded-xl border border-gray-600 flex items-center gap-2 transition-colors shadow-lg hover:shadow-blue-500/20 cursor-pointer">
|
|
<i class="ph ph-gear text-lg text-blue-400"></i> ตั้งค่าแจ้งเตือน
|
|
</button>
|
|
<div class="flex items-center gap-3 bg-gray-800/50 px-4 py-2 rounded-full border border-gray-700">
|
|
<i class="ph ph-clock text-emerald-400"></i>
|
|
<span id="current-time" class="font-mono text-sm tracking-widest text-gray-300">00:00:00</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="glass-card rounded-xl p-4 md:p-6 flex flex-col gap-4 transition-all duration-500 border shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50" id="main-cam-card">
|
|
<!-- Header -->
|
|
<div class="flex flex-wrap justify-between items-center gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-blue-500/20 rounded-lg">
|
|
<i class="ph-fill ph-webcam text-blue-400 text-2xl"></i>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-gray-100 text-lg flex items-center gap-2">
|
|
IMOU-CAM-01
|
|
<span class="px-2 py-0.5 text-[10px] rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">ONLINE</span>
|
|
</h3>
|
|
<p class="text-sm text-gray-400"><i class="ph ph-info mr-1"></i>ลากกรอบสีเขียวคลุมริบบิ้นหน้าช่องแอร์ เพื่อเริ่มตรวจจับ</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- System Overview Badge -->
|
|
<div class="flex items-center gap-3 bg-gray-900/80 px-4 py-2 rounded-lg border border-gray-700">
|
|
<div class="text-right">
|
|
<p class="text-xs text-gray-500">AC Unit Status</p>
|
|
<p class="text-sm font-bold text-gray-200" id="ac-status-label">WAITING</p>
|
|
</div>
|
|
<div class="w-px h-8 bg-gray-700"></div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-3 h-3 rounded-full bg-amber-500 animate-pulse" id="sys-status-dot"></span>
|
|
<span class="text-xs font-mono text-amber-400" id="sys-status-text">DRAW ROI</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MJPEG Camera Feed -->
|
|
<div class="cam-feed rounded-lg aspect-video w-full flex items-center justify-center border border-gray-800 shadow-inner relative">
|
|
<!-- Overlay Info -->
|
|
<div class="absolute top-3 left-3 text-[10px] md:text-xs font-mono text-white bg-black/60 px-2 py-1 rounded z-30 backdrop-blur-sm border border-white/10">
|
|
<i class="ph ph-video-camera mr-1"></i> LIVE STREAM
|
|
</div>
|
|
|
|
<!-- The MJPEG Stream Image -->
|
|
<img id="mjpeg-stream" src="http://localhost:3000/stream" alt="Waiting for stream..." crossorigin="anonymous" onerror="this.onerror=null; this.alt='Stream not available (Is Backend running?)';" />
|
|
|
|
<!-- Drawing Canvas -->
|
|
<canvas id="overlay-canvas"></canvas>
|
|
|
|
<!-- UI Indicators (Ribbon Animation inside the view) -->
|
|
<div id="ui-indicator" class="hidden absolute top-4 right-4 bg-gray-800/80 p-3 rounded-lg border border-gray-600 z-30 flex items-center gap-4">
|
|
<div class="text-xs text-gray-300">Motion State:</div>
|
|
<div class="w-10 h-16 bg-gray-900 rounded flex justify-center items-start pt-1">
|
|
<div id="virtual-ribbon" class="ribbon animate-ribbon-still" style="transform: rotate(0deg);"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="glass-card rounded-2xl p-6 mt-8">
|
|
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-200">
|
|
<i class="ph ph-list-dashes text-blue-400"></i>
|
|
System Event Logs
|
|
</h2>
|
|
<div class="overflow-x-auto">
|
|
<table id="logTable" class="display w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left">Time</th>
|
|
<th class="text-left">Camera / AC Unit</th>
|
|
<th class="text-left">Status</th>
|
|
<th class="text-left">Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Logs will be added here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden canvas for image processing -->
|
|
<canvas id="process-canvas" style="display:none;"></canvas>
|
|
|
|
<!-- Settings Modal -->
|
|
<div id="settings-modal" class="fixed inset-0 z-50 hidden bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="glass-card w-full max-w-md rounded-2xl p-6 border border-gray-700 shadow-2xl transform transition-all">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-bold text-gray-100 flex items-center gap-2">
|
|
<i class="ph ph-bell-ringing text-amber-400"></i> Notification Settings
|
|
</h2>
|
|
<button onclick="closeSettings()" class="text-gray-400 hover:text-white transition-colors">
|
|
<i class="ph ph-x text-2xl"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-5">
|
|
<!-- LINE Notify -->
|
|
<div class="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<div class="flex items-center gap-2">
|
|
<i class="ph-fill ph-chat-circle text-green-500 text-xl"></i>
|
|
<span class="font-semibold text-gray-200">LINE Notify</span>
|
|
</div>
|
|
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
|
<input type="checkbox" name="toggleLine" id="toggleLine" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer" />
|
|
<label for="toggleLine" class="toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer"></label>
|
|
</div>
|
|
</div>
|
|
<input type="password" id="lineToken" placeholder="LINE Notify Token" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-green-500">
|
|
<p class="text-[10px] text-gray-400 mt-1">* หมายเหตุ: LINE API อาจถูก Block (CORS) หากส่งจาก Browser หน้าเว็บโดยตรง (ระบบใช้ Proxy ชั่วคราวให้)</p>
|
|
</div>
|
|
|
|
<!-- Telegram -->
|
|
<div class="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<div class="flex items-center gap-2">
|
|
<i class="ph-fill ph-telegram-logo text-blue-500 text-xl"></i>
|
|
<span class="font-semibold text-gray-200">Telegram Bot</span>
|
|
</div>
|
|
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
|
<input type="checkbox" name="toggleTelegram" id="toggleTelegram" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer" />
|
|
<label for="toggleTelegram" class="toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer"></label>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<input type="password" id="tgBotToken" placeholder="Bot Token (e.g. 123456:ABC-DEF...)" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500">
|
|
<input type="text" id="tgChatId" placeholder="Chat ID (e.g. 123456789)" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button onclick="saveSettings()" class="w-full mt-6 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-semibold py-2 px-4 rounded-xl transition-all shadow-lg shadow-blue-500/30">
|
|
Save Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let logTable;
|
|
let notifyConfig = {
|
|
line: { enabled: false, token: '' },
|
|
telegram: { enabled: false, token: '', chatId: '' }
|
|
};
|
|
|
|
// --- ROI Drawing Variables ---
|
|
const overlayCanvas = document.getElementById('overlay-canvas');
|
|
const overlayCtx = overlayCanvas.getContext('2d');
|
|
let isDrawing = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let roiRect = null; // {x, y, w, h}
|
|
|
|
// --- Motion Detection Variables ---
|
|
const processCanvas = document.getElementById('process-canvas');
|
|
const processCtx = processCanvas.getContext('2d', { willReadFrequently: true });
|
|
const mjpegImg = document.getElementById('mjpeg-stream');
|
|
let previousImageData = null;
|
|
let detectionInterval = null;
|
|
let isAcRunning = false;
|
|
let previousAcState = false; // To track changes and log
|
|
|
|
$(document).ready(function () {
|
|
loadSettings();
|
|
initClock();
|
|
|
|
logTable = $('#logTable').DataTable({
|
|
"order": [[0, "desc"]],
|
|
"pageLength": 5,
|
|
"lengthMenu": [5, 10, 25],
|
|
"language": {
|
|
"emptyTable": "No events recorded yet."
|
|
}
|
|
});
|
|
|
|
addLog("System", "Web UI", "System Start", "Frontend loaded. Waiting for ROI setup.");
|
|
|
|
// Resize canvas to match the image dimensions
|
|
function resizeCanvas() {
|
|
overlayCanvas.width = overlayCanvas.clientWidth;
|
|
overlayCanvas.height = overlayCanvas.clientHeight;
|
|
if (roiRect) drawROI();
|
|
}
|
|
window.addEventListener('resize', resizeCanvas);
|
|
mjpegImg.addEventListener('load', resizeCanvas);
|
|
setTimeout(resizeCanvas, 500);
|
|
|
|
// --- Mouse Events for Drawing ROI ---
|
|
overlayCanvas.addEventListener('mousedown', (e) => {
|
|
const rect = overlayCanvas.getBoundingClientRect();
|
|
startX = e.clientX - rect.left;
|
|
startY = e.clientY - rect.top;
|
|
isDrawing = true;
|
|
roiRect = null; // Reset
|
|
});
|
|
|
|
overlayCanvas.addEventListener('mousemove', (e) => {
|
|
if (!isDrawing) return;
|
|
const rect = overlayCanvas.getBoundingClientRect();
|
|
const currentX = e.clientX - rect.left;
|
|
const currentY = e.clientY - rect.top;
|
|
|
|
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
|
|
const w = currentX - startX;
|
|
const h = currentY - startY;
|
|
|
|
overlayCtx.strokeStyle = '#10b981'; // Emerald 500
|
|
overlayCtx.lineWidth = 2;
|
|
overlayCtx.setLineDash([5, 5]);
|
|
overlayCtx.strokeRect(startX, startY, w, h);
|
|
|
|
overlayCtx.fillStyle = 'rgba(16, 185, 129, 0.2)';
|
|
overlayCtx.fillRect(startX, startY, w, h);
|
|
});
|
|
|
|
overlayCanvas.addEventListener('mouseup', (e) => {
|
|
if (!isDrawing) return;
|
|
isDrawing = false;
|
|
const rect = overlayCanvas.getBoundingClientRect();
|
|
const currentX = e.clientX - rect.left;
|
|
const currentY = e.clientY - rect.top;
|
|
|
|
// Normalize rectangle (width/height can be negative if drawn backwards)
|
|
const x = Math.min(startX, currentX);
|
|
const y = Math.min(startY, currentY);
|
|
const w = Math.abs(currentX - startX);
|
|
const h = Math.abs(currentY - startY);
|
|
|
|
if (w > 10 && h > 10) {
|
|
roiRect = { x, y, w, h };
|
|
drawROI();
|
|
startMotionDetection();
|
|
} else {
|
|
roiRect = null;
|
|
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
stopMotionDetection();
|
|
}
|
|
});
|
|
});
|
|
|
|
function drawROI() {
|
|
if (!roiRect) return;
|
|
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
overlayCtx.strokeStyle = '#10b981';
|
|
overlayCtx.lineWidth = 2;
|
|
overlayCtx.setLineDash([]);
|
|
overlayCtx.strokeRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
|
|
|
|
// Draw label
|
|
overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
overlayCtx.fillRect(roiRect.x, roiRect.y - 20, 100, 20);
|
|
overlayCtx.fillStyle = '#10b981';
|
|
overlayCtx.font = '12px monospace';
|
|
overlayCtx.fillText('ROI: AC Ribbon', roiRect.x + 5, roiRect.y - 5);
|
|
}
|
|
|
|
// --- Motion Detection Logic ---
|
|
function startMotionDetection() {
|
|
if (detectionInterval) clearInterval(detectionInterval);
|
|
previousImageData = null;
|
|
$('#ui-indicator').removeClass('hidden');
|
|
|
|
addLog("IMOU-CAM-01", "ROI Configured", "Info", "Started monitoring AC ribbon.");
|
|
|
|
// Run detection every 1000ms (1 second)
|
|
detectionInterval = setInterval(processFrame, 1000);
|
|
}
|
|
|
|
function stopMotionDetection() {
|
|
if (detectionInterval) clearInterval(detectionInterval);
|
|
$('#ui-indicator').addClass('hidden');
|
|
updateSystemBadge(false, true); // Reset to waiting state
|
|
}
|
|
|
|
function processFrame() {
|
|
if (!roiRect || !mjpegImg.complete || mjpegImg.naturalWidth === 0) return;
|
|
|
|
// Map canvas ROI coordinates to image natural coordinates
|
|
const scaleX = mjpegImg.naturalWidth / overlayCanvas.width;
|
|
const scaleY = mjpegImg.naturalHeight / overlayCanvas.height;
|
|
|
|
const sx = roiRect.x * scaleX;
|
|
const sy = roiRect.y * scaleY;
|
|
const sw = roiRect.w * scaleX;
|
|
const sh = roiRect.h * scaleY;
|
|
|
|
processCanvas.width = sw;
|
|
processCanvas.height = sh;
|
|
|
|
// Draw the ROI part of the image onto the hidden canvas
|
|
try {
|
|
processCtx.drawImage(mjpegImg, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
} catch(e) {
|
|
// If stream is broken, ignore
|
|
return;
|
|
}
|
|
|
|
const currentImageData = processCtx.getImageData(0, 0, sw, sh);
|
|
|
|
if (previousImageData) {
|
|
// Compare pixels
|
|
let diffPixels = 0;
|
|
const threshold = 30; // Pixel color difference threshold
|
|
|
|
for (let i = 0; i < currentImageData.data.length; i += 4) {
|
|
const rDiff = Math.abs(currentImageData.data[i] - previousImageData.data[i]);
|
|
const gDiff = Math.abs(currentImageData.data[i+1] - previousImageData.data[i+1]);
|
|
const bDiff = Math.abs(currentImageData.data[i+2] - previousImageData.data[i+2]);
|
|
|
|
if (rDiff > threshold || gDiff > threshold || bDiff > threshold) {
|
|
diffPixels++;
|
|
}
|
|
}
|
|
|
|
const totalPixels = sw * sh;
|
|
const diffRatio = diffPixels / totalPixels;
|
|
|
|
// If more than 5% of pixels changed, we consider it "motion"
|
|
isAcRunning = diffRatio > 0.05;
|
|
|
|
updateUIState(isAcRunning);
|
|
}
|
|
|
|
// Save current frame as previous for next iteration
|
|
previousImageData = currentImageData;
|
|
}
|
|
|
|
function updateUIState(isRunning) {
|
|
const ribbon = $('#virtual-ribbon');
|
|
|
|
if (isRunning) {
|
|
ribbon.removeClass('animate-ribbon-still').addClass('animate-ribbon-flutter').css('transform', '');
|
|
updateSystemBadge(true, false);
|
|
} else {
|
|
ribbon.removeClass('animate-ribbon-flutter').addClass('animate-ribbon-still').css('transform', 'rotate(0deg)');
|
|
updateSystemBadge(false, false);
|
|
}
|
|
|
|
// Log state changes
|
|
if (isRunning !== previousAcState) {
|
|
if (isRunning) {
|
|
showToast(`Recovery: Airflow restored`, 'success');
|
|
addLog("IMOU-CAM-01", "AC-1", `<span class="text-emerald-400 font-medium">RUNNING</span>`, `Ribbon motion detected.`);
|
|
sendNotification(`✅ RECOVERY: AC Started\nStatus: Airflow Restored.`);
|
|
} else {
|
|
showToast(`Alert: No airflow detected`, 'error');
|
|
addLog("IMOU-CAM-01", "AC-1", `<span class="text-red-400 font-medium">STOPPED</span>`, `Ribbon motion ceased.`);
|
|
sendNotification(`🚨 ALERT: AC Stopped!\nStatus: No Airflow Detected.`);
|
|
}
|
|
previousAcState = isRunning;
|
|
}
|
|
}
|
|
|
|
function updateSystemBadge(isRunning, isWaiting) {
|
|
const sysDot = $('#sys-status-dot');
|
|
const sysText = $('#sys-status-text');
|
|
const mainCard = $('#main-cam-card');
|
|
const acStatusLabel = $('#ac-status-label');
|
|
|
|
if (isWaiting) {
|
|
sysDot.removeClass('bg-red-500 bg-emerald-500').addClass('bg-amber-500 animate-pulse');
|
|
sysText.removeClass('text-red-400 text-emerald-400').addClass('text-amber-400').text('DRAW ROI');
|
|
mainCard.removeClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40').addClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50');
|
|
acStatusLabel.text('WAITING').removeClass('text-emerald-400 text-red-400').addClass('text-gray-200');
|
|
} else if (isRunning) {
|
|
sysDot.removeClass('bg-red-500 bg-amber-500').addClass('bg-emerald-500 animate-pulse');
|
|
sysText.removeClass('text-red-400 text-amber-400').addClass('text-emerald-400').text('NORMAL');
|
|
mainCard.removeClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40 shadow-[0_0_20px_rgba(245,158,11,0.2)] border-amber-900/40').addClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50');
|
|
acStatusLabel.text('AIRFLOW DETECTED').removeClass('text-gray-200 text-red-400').addClass('text-emerald-400');
|
|
} else {
|
|
sysDot.removeClass('bg-emerald-500 bg-amber-500 animate-pulse').addClass('bg-red-500 animate-pulse');
|
|
sysText.removeClass('text-emerald-400 text-amber-400').addClass('text-red-400').text('CRITICAL');
|
|
mainCard.removeClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50 shadow-[0_0_20px_rgba(245,158,11,0.2)] border-amber-900/40').addClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40');
|
|
acStatusLabel.text('NO AIRFLOW').removeClass('text-gray-200 text-emerald-400').addClass('text-red-400');
|
|
}
|
|
}
|
|
|
|
// --- Settings & Notification Logic ---
|
|
function loadSettings() {
|
|
const saved = localStorage.getItem('imouAcMonitorConfig');
|
|
if (saved) {
|
|
notifyConfig = JSON.parse(saved);
|
|
$('#toggleLine').prop('checked', notifyConfig.line.enabled);
|
|
$('#lineToken').val(notifyConfig.line.token);
|
|
$('#toggleTelegram').prop('checked', notifyConfig.telegram.enabled);
|
|
$('#tgBotToken').val(notifyConfig.telegram.token);
|
|
$('#tgChatId').val(notifyConfig.telegram.chatId);
|
|
}
|
|
}
|
|
|
|
function openSettings() { $('#settings-modal').removeClass('hidden'); }
|
|
function closeSettings() { $('#settings-modal').addClass('hidden'); }
|
|
|
|
function saveSettings() {
|
|
notifyConfig.line.enabled = $('#toggleLine').is(':checked');
|
|
notifyConfig.line.token = $('#lineToken').val();
|
|
notifyConfig.telegram.enabled = $('#toggleTelegram').is(':checked');
|
|
notifyConfig.telegram.token = $('#tgBotToken').val();
|
|
notifyConfig.telegram.chatId = $('#tgChatId').val();
|
|
|
|
localStorage.setItem('imouAcMonitorConfig', JSON.stringify(notifyConfig));
|
|
closeSettings();
|
|
showToast('Settings saved successfully', 'success');
|
|
}
|
|
|
|
async function sendNotification(message) {
|
|
const time = new Date().toLocaleTimeString('th-TH');
|
|
const fullMessage = `[${time}]\n${message}`;
|
|
|
|
if (notifyConfig.telegram.enabled && notifyConfig.telegram.token && notifyConfig.telegram.chatId) {
|
|
const url = `https://api.telegram.org/bot${notifyConfig.telegram.token}/sendMessage`;
|
|
try {
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ chat_id: notifyConfig.telegram.chatId, text: fullMessage })
|
|
}).catch(e => console.error("Telegram error:", e));
|
|
} catch (error) { console.error("Telegram request failed", error); }
|
|
}
|
|
|
|
if (notifyConfig.line.enabled && notifyConfig.line.token) {
|
|
const proxyUrl = 'https://corsproxy.io/?';
|
|
const targetUrl = 'https://notify-api.line.me/api/notify';
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('message', fullMessage);
|
|
fetch(proxyUrl + encodeURIComponent(targetUrl), {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${notifyConfig.line.token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: params
|
|
}).catch(e => console.error("Line API CORS Error", e));
|
|
} catch (error) { console.error("Line request failed", error); }
|
|
}
|
|
}
|
|
|
|
function initClock() {
|
|
setInterval(() => {
|
|
const now = new Date();
|
|
$('#current-time').text(now.toLocaleTimeString('th-TH', { hour12: false }));
|
|
}, 1000);
|
|
}
|
|
|
|
function showToast(message, iconType) {
|
|
const Toast = Swal.mixin({
|
|
toast: true,
|
|
position: 'top-end',
|
|
showConfirmButton: false,
|
|
timer: 4000,
|
|
timerProgressBar: true,
|
|
background: '#1f2937',
|
|
color: '#fff',
|
|
iconColor: iconType === 'error' ? '#ef4444' : '#10b981',
|
|
didOpen: (toast) => {
|
|
toast.addEventListener('mouseenter', Swal.stopTimer)
|
|
toast.addEventListener('mouseleave', Swal.resumeTimer)
|
|
}
|
|
});
|
|
Toast.fire({ icon: iconType, title: message });
|
|
}
|
|
|
|
function addLog(camName, location, statusHtml, details) {
|
|
const now = new Date();
|
|
const timeStr = now.toLocaleTimeString('th-TH', { hour12: false }) + '.' + now.getMilliseconds().toString().padStart(3, '0');
|
|
|
|
logTable.row.add([
|
|
`<span class="text-gray-400 font-mono text-xs">${timeStr}</span>`,
|
|
`<span class="font-medium text-gray-200">${camName}</span> <br><span class="text-xs text-gray-500">${location}</span>`,
|
|
statusHtml,
|
|
`<span class="text-gray-400 text-sm">${details}</span>`
|
|
]).draw(false);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|