720 lines
30 KiB
PHP
720 lines
30 KiB
PHP
<?php
|
|
$title = 'Dashboard - ESP Admin';
|
|
ob_start();
|
|
?>
|
|
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-slate-900 flex items-center gap-2">
|
|
<svg class="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
</svg>
|
|
Dashboard
|
|
</h1>
|
|
<p id="device-title" class="text-slate-600 mt-1">Cargando información del dispositivo...</p>
|
|
</div>
|
|
<a href="/" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-50 border-2 border-slate-200 shadow-sm transition-all">
|
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Volver
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contenedor principal -->
|
|
<div class="space-y-6">
|
|
<!-- Cards de Estado -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<!-- Estado de Conexión -->
|
|
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-slate-900">Estado</h3>
|
|
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span id="connection-status" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-slate-100 text-slate-700">
|
|
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
|
|
Verificando...
|
|
</span>
|
|
</div>
|
|
<div class="mt-4 text-sm text-slate-600">
|
|
<span class="font-medium">IP:</span> <span id="device-ip" class="font-mono">Cargando...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Última Actividad -->
|
|
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-slate-900">Actividad</h3>
|
|
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div class="text-2xl font-bold text-slate-900" id="last-activity">-</div>
|
|
<div class="mt-2 text-sm text-slate-600">Última actualización</div>
|
|
</div>
|
|
|
|
<!-- Acciones Rápidas -->
|
|
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-slate-900">Acciones</h3>
|
|
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<button id="btn-refresh" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition-all">
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
Actualizar
|
|
</button>
|
|
<button id="btn-reboot" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-white text-red-600 hover:bg-red-50 border-2 border-red-200 transition-all">
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
Reiniciar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Estados de Pines -->
|
|
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-emerald-50 to-emerald-100">
|
|
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
Estados de Pines
|
|
</h2>
|
|
<p class="text-xs text-slate-600 mt-1">Actualización en tiempo real</p>
|
|
</div>
|
|
<div class="p-6">
|
|
<div id="pins-status" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
|
<div class="text-center text-slate-500 col-span-full py-4">Cargando estados de pines...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controles Rapidos -->
|
|
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-blue-50 to-blue-100">
|
|
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
|
</svg>
|
|
Controles Rapidos
|
|
</h2>
|
|
<p class="text-xs text-slate-600 mt-1">Control manual de salidas</p>
|
|
</div>
|
|
<div class="p-6">
|
|
<div id="quick-controls" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
<div class="text-center text-slate-500 col-span-full py-4">Cargando controles...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botones de Acción Adicionales -->
|
|
<div class="flex flex-wrap gap-3">
|
|
<button id="btn-config-ports" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-blue-600 hover:bg-blue-50 border-2 border-blue-200 transition-all">
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
Configurar Puertos
|
|
</button>
|
|
|
|
<button id="btn-mqtt" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-purple-600 hover:bg-purple-50 border-2 border-purple-200 transition-all">
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
</svg>
|
|
Ver Info MQTT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal MQTT -->
|
|
<div id="mqttModal" class="fixed inset-0 z-50 hidden" aria-hidden="true">
|
|
<div id="mqttModalOverlay" class="absolute inset-0 bg-black/40"></div>
|
|
<div class="relative mx-auto mt-24 w-11/12 max-w-2xl">
|
|
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200">
|
|
<h3 class="text-lg font-semibold">Información MQTT</h3>
|
|
<button id="mqttModalClose" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100" aria-label="Cerrar">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="p-4">
|
|
<pre id="mqttJson" class="text-sm bg-gray-50 p-3 rounded border border-gray-200 overflow-auto max-h-96">{}</pre>
|
|
</div>
|
|
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-200">
|
|
<button id="mqttModalClose2" class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded border border-gray-300 text-gray-700 hover:bg-gray-50">Cerrar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
let chipid = urlParams.get('chipid');
|
|
if (chipid) chipid = String(chipid).trim().toUpperCase();
|
|
|
|
if (!chipid) {
|
|
document.getElementById('device-info').innerHTML = '<div class="alert alert-danger">Error: No se especificó un ChipID en la URL.</div>';
|
|
throw new Error('Falta el parámetro chipid');
|
|
}
|
|
|
|
let deviceData = {};
|
|
let pinsData = {};
|
|
let lastUpdateTime = null;
|
|
let lastKnownIP = null;
|
|
|
|
// Estados actuales por chipid
|
|
const estadosDispositivos = {};
|
|
// Estado conocido de cada pin ("ON"|"OFF"|otro)
|
|
const pinStates = {};
|
|
// Pines en tiempo real
|
|
const pinesEstados = {};
|
|
const pendingPinCommands = {};
|
|
|
|
// Comparación de chipid insensible a mayúsculas/minúsculas
|
|
function sameChip(a, b) {
|
|
if (!a || !b) return false;
|
|
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
|
|
}
|
|
|
|
function isPinOn(valor) {
|
|
const v = String(normalizePinValue(valor) || '').toLowerCase().trim();
|
|
return v === 'on' || v === '1' || v === 'true' || v === 'high';
|
|
}
|
|
|
|
function normalizePinValue(valor) {
|
|
if (valor && typeof valor === 'object') {
|
|
if (Object.prototype.hasOwnProperty.call(valor, 'valor')) return valor.valor;
|
|
if (Object.prototype.hasOwnProperty.call(valor, 'value')) return valor.value;
|
|
}
|
|
return valor;
|
|
}
|
|
|
|
function setSwitchVisual(switchEl, isOn, options) {
|
|
if (!switchEl) return;
|
|
const opts = options || {};
|
|
const card = switchEl.closest('.quick-control-card') || switchEl.closest('.bg-white');
|
|
const root = card || switchEl.closest('label') || switchEl.parentElement;
|
|
const label = root ? root.querySelector('.state-label') : null;
|
|
const track = root ? root.querySelector('.switch-track') : null;
|
|
const thumb = root ? root.querySelector('.switch-thumb') : null;
|
|
const icon = card ? card.querySelector('svg') : null;
|
|
const stateColor = opts.pending ? 'text-amber-700' : (isOn ? 'text-emerald-700' : 'text-red-700');
|
|
const trackColor = opts.pending ? 'bg-amber-500' : (isOn ? 'bg-emerald-500' : 'bg-red-500');
|
|
|
|
switchEl.checked = !!isOn;
|
|
|
|
if (label) {
|
|
label.textContent = isOn ? 'ON' : 'OFF';
|
|
label.className = `state-label text-sm font-medium ${stateColor}`;
|
|
}
|
|
|
|
if (track) {
|
|
track.className = `switch-track relative w-14 h-8 rounded-full transition-colors duration-300 ${trackColor}`;
|
|
if (opts.pulse) {
|
|
track.classList.add('animate-pulse');
|
|
setTimeout(() => track.classList.remove('animate-pulse'), 300);
|
|
}
|
|
}
|
|
|
|
if (thumb) {
|
|
thumb.className = `switch-thumb absolute left-1 top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${isOn ? 'translate-x-6' : 'translate-x-0'}`;
|
|
}
|
|
|
|
if (icon) {
|
|
icon.className = `w-5 h-5 ${isOn ? 'text-emerald-500' : 'text-slate-400'}`;
|
|
}
|
|
}
|
|
|
|
function clearPendingPin(pin) {
|
|
const pending = pendingPinCommands[pin];
|
|
if (!pending) return null;
|
|
clearTimeout(pending.timer);
|
|
delete pendingPinCommands[pin];
|
|
return pending;
|
|
}
|
|
|
|
function confirmPendingPin(pin, valor) {
|
|
const pending = pendingPinCommands[pin];
|
|
if (!pending) return;
|
|
|
|
const received = isPinOn(normalizePinValue(valor)) ? 'ON' : 'OFF';
|
|
if (received !== pending.expected) return;
|
|
|
|
clearPendingPin(pin);
|
|
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`) || pending.switchEl;
|
|
if (sw) {
|
|
setSwitchVisual(sw, pending.expected === 'ON');
|
|
sw.disabled = false;
|
|
}
|
|
mostrarToast(`${pin} confirmado ${pending.expected}`, { type: 'success', delay: 1800 });
|
|
}
|
|
|
|
function failPendingPin(pin, message) {
|
|
const pending = clearPendingPin(pin);
|
|
if (!pending) return;
|
|
|
|
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`) || pending.switchEl;
|
|
if (sw) {
|
|
setSwitchVisual(sw, pending.previous === 'ON', { pulse: true });
|
|
sw.disabled = false;
|
|
}
|
|
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
|
|
pinesEstados[chipid][pin] = pending.previous;
|
|
let toastMessage = message || `No se pudo enviar comando a ${pin}`;
|
|
if (String(toastMessage).indexOf('WebSocket JSON') !== -1) {
|
|
toastMessage = `Comando enviado, pero ${pin} no confirmo ${pending.expected} en valores/${pin}. El dispositivo sigue reportando otro estado.`;
|
|
}
|
|
mostrarToast(toastMessage, { type: 'danger', delay: 5000 });
|
|
}
|
|
|
|
function updateConnectionStatus(estado) {
|
|
const statusElement = document.getElementById('connection-status');
|
|
if (estado === 'online') {
|
|
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Online';
|
|
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-emerald-100 text-emerald-700';
|
|
} else if (estado === 'offline') {
|
|
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-red-500"></span> Offline';
|
|
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-red-100 text-red-700';
|
|
} else {
|
|
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-slate-400"></span> Desconocido';
|
|
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-slate-100 text-slate-700';
|
|
}
|
|
}
|
|
|
|
function updateLastActivity() {
|
|
const element = document.getElementById('last-activity');
|
|
if (lastUpdateTime) {
|
|
const now = new Date();
|
|
const diff = Math.floor((now - lastUpdateTime) / 1000);
|
|
|
|
if (diff < 10) {
|
|
element.textContent = 'Ahora mismo';
|
|
} else if (diff < 60) {
|
|
element.textContent = `Hace ${diff} segundos`;
|
|
} else if (diff < 3600) {
|
|
const minutes = Math.floor(diff / 60);
|
|
element.textContent = `Hace ${minutes} minuto${minutes !== 1 ? 's' : ''}`;
|
|
} else if (diff < 86400) {
|
|
const hours = Math.floor(diff / 3600);
|
|
element.textContent = `Hace ${hours} hora${hours !== 1 ? 's' : ''}`;
|
|
} else {
|
|
const days = Math.floor(diff / 86400);
|
|
element.textContent = `Hace ${days} día${days !== 1 ? 's' : ''}`;
|
|
}
|
|
} else {
|
|
element.textContent = 'Sin datos';
|
|
}
|
|
}
|
|
|
|
function loadDeviceInfo() {
|
|
api.get('/api/get_dispositivos').then(data => {
|
|
const deviceKey = Object.keys(data || {}).find(key => sameChip(key, chipid));
|
|
if (deviceKey) {
|
|
deviceData = data[deviceKey];
|
|
document.getElementById('device-title').innerHTML =
|
|
`<strong>${deviceData.tag}</strong> (${deviceData.chipid}) - ${deviceData.ciudad}, ${deviceData.sector}`;
|
|
const ipEl = document.getElementById('device-ip');
|
|
if (ipEl) {
|
|
ipEl.textContent = lastKnownIP || deviceData.ip || 'No disponible';
|
|
}
|
|
|
|
// Configurar botón a puertos
|
|
(function(){
|
|
const btn = document.getElementById('btn-config-ports');
|
|
if (btn) {
|
|
btn.onclick = function(){ window.location.href = `/puertos?chipid=${chipid}`; };
|
|
btn.disabled = false;
|
|
}
|
|
})();
|
|
|
|
// Estado inicial: usar API como fallback
|
|
updateConnectionStatus(deviceData.online ? 'online' : 'offline');
|
|
if (deviceData.pines && typeof deviceData.pines === 'object') {
|
|
updateDispositivoPines(chipid, deviceData.pines);
|
|
Object.keys(deviceData.pines).forEach(pin => updatePinStatus(pin, deviceData.pines[pin]));
|
|
}
|
|
} else {
|
|
document.getElementById('device-info').innerHTML =
|
|
'<div class="alert alert-warning">Dispositivo no encontrado en el registro.</div>';
|
|
}
|
|
}).catch(err => {
|
|
console.error('Error cargando dispositivo:', err);
|
|
document.getElementById('device-info').innerHTML =
|
|
'<div class="alert alert-danger">Error al cargar información del dispositivo.</div>';
|
|
});
|
|
}
|
|
|
|
function loadPinsStatus() {
|
|
api.get('/api/get_puertos', { chipid: chipid }).then(data => {
|
|
if (data && !data.error) {
|
|
pinsData = data;
|
|
renderPinsStatus();
|
|
renderQuickControls();
|
|
|
|
// Forzar sincronización inicial: solicitar estado actual si no hay datos
|
|
setTimeout(() => {
|
|
Object.keys(pinsData).forEach(pin => {
|
|
if (pin === 'update') return;
|
|
const p = pinsData[pin];
|
|
if (p.modo === 'OUTPUT') {
|
|
// El estado inicial llega por MQTT WebSocket.
|
|
}
|
|
});
|
|
}, 500);
|
|
|
|
// Re-render leve luego para aplicar estados iniciales
|
|
setTimeout(renderQuickControls, 800);
|
|
} else {
|
|
document.getElementById('pins-status').innerHTML =
|
|
'<div class="col-12"><div class="alert alert-info">No hay configuración de puertos para este dispositivo.</div></div>';
|
|
}
|
|
}).catch(err => {
|
|
console.error('Error cargando pines:', err);
|
|
document.getElementById('pins-status').innerHTML =
|
|
'<div class="col-12"><div class="alert alert-danger">Error al cargar estados de pines.</div></div>';
|
|
});
|
|
}
|
|
|
|
function renderPinsStatus() {
|
|
let html = '';
|
|
for (let pin in pinsData) {
|
|
if (pin === 'update') continue;
|
|
const p = pinsData[pin];
|
|
const modo = p.modo || '';
|
|
|
|
// Estilo inicial según modo
|
|
let badgeCls = 'bg-slate-100 text-slate-700 border-slate-200';
|
|
let badgeText = 'Sin datos';
|
|
let iconColor = 'text-slate-400';
|
|
|
|
if (modo === 'OUTPUT') {
|
|
badgeCls = 'bg-amber-100 text-amber-700 border-amber-200';
|
|
badgeText = 'Salida';
|
|
iconColor = 'text-amber-500';
|
|
} else if (modo === 'INPUT' || modo === 'INPUT_PULLUP') {
|
|
badgeCls = 'bg-blue-100 text-blue-700 border-blue-200';
|
|
badgeText = 'Entrada';
|
|
iconColor = 'text-blue-500';
|
|
}
|
|
|
|
html += `
|
|
<div class="bg-white rounded-lg border-2 border-slate-200 p-4 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="font-mono text-sm font-bold text-slate-900">${pin}</span>
|
|
<svg class="w-4 h-4 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<span class="pin-status inline-flex items-center justify-center w-full px-2 py-1 text-xs font-semibold rounded-md border-2 ${badgeCls}" data-pin="${pin}">${badgeText}</span>
|
|
<div class="mt-2 text-xs text-slate-600 text-center truncate" title="${p.disp || 'Sin asignar'}">${p.disp || 'Sin asignar'}</div>
|
|
</div>`;
|
|
}
|
|
document.getElementById('pins-status').innerHTML = html;
|
|
}
|
|
|
|
function updatePinStatus(pin, valor) {
|
|
const badge = document.querySelector(`.pin-status[data-pin="${pin}"]`);
|
|
const prev = String(pinStates[pin] || '');
|
|
const normalizedInput = normalizePinValue(valor);
|
|
const v = String(normalizedInput || '').toLowerCase().trim();
|
|
const isOn = isPinOn(normalizedInput);
|
|
const normalizedValue = isOn ? 'ON' : 'OFF';
|
|
|
|
console.log(`updatePinStatus: ${pin}="${valor}" -> normalized="${normalizedValue}" (${isOn})`); // Debug
|
|
|
|
if (badge) {
|
|
const base = 'pin-status inline-flex items-center justify-center w-full px-2 py-1 text-xs font-semibold rounded-md border-2';
|
|
let variant = 'bg-slate-100 text-slate-700 border-slate-200';
|
|
if (isOn) variant = 'bg-emerald-100 text-emerald-700 border-emerald-300';
|
|
else if (!isOn && v !== '') variant = 'bg-red-100 text-red-700 border-red-300';
|
|
badge.className = base + ' ' + variant;
|
|
badge.textContent = isOn ? 'ON' : 'OFF';
|
|
}
|
|
|
|
// Guardar estado normalizado y reflejar en el switch si existe
|
|
pinStates[pin] = normalizedValue;
|
|
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`);
|
|
if (sw) {
|
|
console.log(`Setting switch ${pin} checked=${isOn}`); // Debug
|
|
setSwitchVisual(sw, isOn, { pulse: prev !== normalizedValue });
|
|
}
|
|
}
|
|
|
|
function renderQuickControls() {
|
|
const container = document.getElementById('quick-controls');
|
|
if (!container) return;
|
|
|
|
|
|
|
|
// Verificar si ya hay controles, entonces solo actualizar estados
|
|
const existingSwitches = container.querySelectorAll('.switch-pin');
|
|
if (existingSwitches.length > 0) {
|
|
// Solo actualizar estados de switches existentes
|
|
existingSwitches.forEach(switchEl => {
|
|
const pin = switchEl.dataset.pin;
|
|
const pinValue = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF'; // Estado dinámico
|
|
if (pendingPinCommands[pin]) {
|
|
setSwitchVisual(switchEl, pendingPinCommands[pin].expected === 'ON', { pending: true });
|
|
} else {
|
|
setSwitchVisual(switchEl, isPinOn(pinValue));
|
|
}
|
|
});
|
|
|
|
return; // No reconstruir el HTML
|
|
}
|
|
|
|
// Primera vez: construir HTML completo
|
|
let html = '';
|
|
for (let pin in pinsData) {
|
|
if (pin === 'update') continue;
|
|
const p = pinsData[pin];
|
|
|
|
if (p.modo === 'OUTPUT' && p.disp) {
|
|
const pinValue = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF'; // Usar estado dinámico
|
|
const isOn = isPinOn(pinValue);
|
|
const notas = p.notas ? `<div class="text-xs text-slate-600 italic mb-2">${p.notas}</div>` : '';
|
|
|
|
html += `
|
|
<div class="quick-control-card bg-white rounded-lg border-2 border-slate-200 p-4 hover:shadow-md transition-all">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex-1">
|
|
<div class="font-semibold text-slate-900">${p.disp}</div>
|
|
<div class="text-xs text-slate-500 font-mono">${pin}</div>
|
|
</div>
|
|
<svg class="w-5 h-5 ${isOn ? 'text-emerald-500' : 'text-slate-400'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
${notas}
|
|
<label class="flex items-center justify-between cursor-pointer select-none">
|
|
<span class="state-label text-sm font-medium ${isOn ? 'text-emerald-700' : 'text-red-700'}">${isOn ? 'ON' : 'OFF'}</span>
|
|
<div class="relative inline-flex items-center">
|
|
<input class="switch-pin absolute opacity-0 w-0 h-0" type="checkbox" data-pin="${pin}" ${isOn ? 'checked' : ''}>
|
|
<div class="switch-track relative w-14 h-8 rounded-full transition-colors duration-300 ${isOn ? 'bg-emerald-500' : 'bg-red-500'}">
|
|
<div class="switch-thumb absolute left-1 top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${isOn ? 'translate-x-6' : 'translate-x-0'}"></div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
if (html === '') {
|
|
html = '<div class="col-span-full text-center text-slate-500 py-8">No hay salidas configuradas para controlar.</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function refreshDashboard() {
|
|
loadDeviceInfo();
|
|
loadPinsStatus();
|
|
mostrarToast('Dashboard actualizado');
|
|
}
|
|
|
|
|
|
|
|
// Construir objeto de información MQTT (desde estado y config de pines)
|
|
function buildMqttInfo() {
|
|
try {
|
|
const estado = deviceData && deviceData.online ? 'online' : 'offline';
|
|
const info = {
|
|
chipid: chipid,
|
|
estado: estado,
|
|
ip: lastKnownIP || '',
|
|
firmware: (deviceData && deviceData.firmware) ? String(deviceData.firmware) : null,
|
|
pines: {}
|
|
};
|
|
// Combinar config de pines con estado ON/OFF si se conoce
|
|
Object.keys(pinsData || {}).forEach(function(pin){
|
|
if (pin === 'update') return;
|
|
const p = pinsData[pin] || {};
|
|
info.pines[pin] = {
|
|
disp: p.disp || null,
|
|
modo: p.modo || null,
|
|
estado: (pinesEstados[chipid] && pinesEstados[chipid][pin]) || null
|
|
};
|
|
});
|
|
return info;
|
|
} catch(e) {
|
|
return { error: true, message: 'No se pudo construir la información', detail: String(e) };
|
|
}
|
|
}
|
|
|
|
// Event listeners
|
|
document.getElementById('btn-reboot').addEventListener('click', function() {
|
|
if (!confirm(`¿Confirmas reiniciar el dispositivo ${chipid}?`)) return;
|
|
|
|
const topic = `dispositivo/${chipid}/comando/reboot`;
|
|
const payload = '1';
|
|
|
|
const publisher = window.mqttRealtime
|
|
? window.mqttRealtime.publish(topic, payload)
|
|
: api.postJSON('/api/mqtt_publish', { topic, payload });
|
|
|
|
publisher
|
|
.then(function(resp) {
|
|
mostrarToast(`Comando de reinicio enviado al dispositivo ${chipid}.`);
|
|
document.getElementById('btn-reboot').disabled = true;
|
|
})
|
|
.catch(function(err) {
|
|
mostrarToast('Error al enviar el comando de reinicio.');
|
|
console.error(err);
|
|
});
|
|
});
|
|
|
|
document.getElementById('btn-refresh').addEventListener('click', refreshDashboard);
|
|
|
|
// Modal MQTT: abrir/cerrar y pintar JSON
|
|
document.getElementById('btn-mqtt').addEventListener('click', function(){
|
|
try {
|
|
const modal = document.getElementById('mqttModal');
|
|
const pre = document.getElementById('mqttJson');
|
|
const data = buildMqttInfo();
|
|
pre.textContent = JSON.stringify(data, null, 2);
|
|
modal.classList.remove('hidden');
|
|
} catch(e) { console.error(e); }
|
|
});
|
|
|
|
|
|
|
|
function closeMqttModal(){
|
|
const modal = document.getElementById('mqttModal');
|
|
if (modal) modal.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('mqttModalClose').addEventListener('click', closeMqttModal);
|
|
document.getElementById('mqttModalClose2').addEventListener('click', closeMqttModal);
|
|
document.getElementById('mqttModalOverlay').addEventListener('click', closeMqttModal);
|
|
|
|
// Control de pines con switch (ON/OFF vía MQTT) - Delegación de eventos
|
|
document.addEventListener('change', function(e) {
|
|
const switchPin = e.target.closest('.switch-pin');
|
|
if (!switchPin) return;
|
|
|
|
const pin = switchPin.dataset.pin;
|
|
const checked = switchPin.checked;
|
|
const action = checked ? 'ON' : 'OFF';
|
|
const prevAction = checked ? 'OFF' : 'ON';
|
|
switchPin.disabled = true;
|
|
|
|
// Feedback visual mínimo mientras llega la confirmación por MQTT.
|
|
const label = switchPin.closest('label')?.querySelector('.state-label');
|
|
const track = switchPin.closest('label')?.querySelector('.switch-track');
|
|
if (label) label.textContent = action;
|
|
if (track) {
|
|
track.className = 'switch-track relative w-14 h-8 rounded-full transition-colors duration-300 bg-amber-500';
|
|
track.classList.add('animate-pulse');
|
|
}
|
|
setSwitchVisual(switchPin, checked, { pending: true, pulse: true });
|
|
clearPendingPin(pin);
|
|
pendingPinCommands[pin] = {
|
|
expected: action,
|
|
previous: prevAction,
|
|
switchEl: switchPin,
|
|
timer: setTimeout(function() {
|
|
failPendingPin(pin, `No se confirmó el envío a ${pin}. Revisá la conexión WebSocket JSON o el estado del dispositivo.`);
|
|
}, 6000)
|
|
};
|
|
|
|
console.log(`Switch clicked: ${pin} -> ${action}`); // Debug
|
|
|
|
const topic = `dispositivo/${chipid}/comando`;
|
|
const payload = { alias: String(pin), valor: String(action) };
|
|
|
|
const publisher = window.mqttRealtime
|
|
? window.mqttRealtime.publish(topic, payload, { qos: 0, retain: true })
|
|
: api.postJSON('/api/mqtt_publish', { topic, payload, qos: 0, retain: true });
|
|
|
|
publisher
|
|
.then(function() {
|
|
console.log(`MQTT published: ${topic} -> ${JSON.stringify(payload)}`); // Debug log
|
|
mostrarToast(`Enviando ${action} a ${pin}...`, { type: 'warning', delay: 1800 });
|
|
})
|
|
.catch(function(err) {
|
|
const msg = (err.data && (err.data.message || err.data.msg)) || err.message || 'Error al enviar comando';
|
|
failPendingPin(pin, msg);
|
|
console.error('mqtt_publish error', err);
|
|
});
|
|
});
|
|
|
|
function updateDispositivoPines(chipid, pines) {
|
|
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
|
|
Object.keys(pines || {}).forEach(function(pin) {
|
|
pinesEstados[chipid][pin] = normalizePinValue(pines[pin]);
|
|
});
|
|
renderQuickControls(); // Re-renderizar controles
|
|
Object.keys(pines || {}).forEach(pin => confirmPendingPin(pin, pines[pin]));
|
|
}
|
|
|
|
function isValueStateTopic(topic) {
|
|
return /\/valores\/[^/]+$/.test('/' + String(topic || '').replace(/^\/+/, ''));
|
|
}
|
|
|
|
function refreshDashboard() {
|
|
loadDeviceInfo();
|
|
loadPinsStatus();
|
|
mostrarToast('Dashboard actualizado');
|
|
}
|
|
|
|
// Inicialización
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadDeviceInfo();
|
|
loadPinsStatus();
|
|
// Inicializar contador de actividad
|
|
updateLastActivity();
|
|
// Re-renderizar controles tras un breve tiempo para reflejar estados iniciales
|
|
setTimeout(renderQuickControls, 1200);
|
|
if (window.mqttRealtime) {
|
|
window.mqttRealtime.onMessage(function(evt) {
|
|
if (!sameChip(evt.chipid, chipid)) {
|
|
console.debug('[Dashboard MQTT] evento ignorado', evt);
|
|
return;
|
|
}
|
|
console.debug('[Dashboard MQTT] evento recibido', evt);
|
|
|
|
if (evt.type === 'state') {
|
|
if (deviceData) deviceData.online = evt.estado === 'online';
|
|
updateConnectionStatus(evt.estado);
|
|
}
|
|
|
|
if (evt.type === 'ip') {
|
|
lastKnownIP = evt.ip || lastKnownIP;
|
|
const ipEl = document.getElementById('device-ip');
|
|
if (ipEl && evt.ip) ipEl.textContent = evt.ip;
|
|
}
|
|
|
|
if (evt.type === 'pin') {
|
|
if (!isValueStateTopic(evt.topic)) {
|
|
console.debug('[Dashboard MQTT] comando publicado, esperando valores/<pin>', evt);
|
|
return;
|
|
}
|
|
|
|
updateDispositivoPines(chipid, { [evt.alias]: evt.valor });
|
|
updatePinStatus(evt.alias, evt.valor);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
<?php
|
|
$content = ob_get_clean();
|
|
// Si no se está renderizando como parcial, incluir el layout principal
|
|
if (!defined('RENDER_PARTIAL')) {
|
|
include __DIR__ . '/../layouts/layout.php';
|
|
}
|
|
?>
|