esp/paginas/flash.php

474 lines
23 KiB
PHP

<?php
$title = 'Flashear ESP8266 - 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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Flashear ESP8266
</h1>
<p class="text-slate-600 mt-1">Actualiza el firmware de tus dispositivos vía USB o OTA</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="max-w-5xl mx-auto space-y-6">
<!-- Flasheo USB -->
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Flasheo USB (Web Serial)
</h2>
<p class="text-xs text-slate-600 mt-1">Conecta tu ESP8266 vía USB y flashea directamente desde el navegador</p>
</div>
<div class="p-6 space-y-4">
<!-- Requisitos -->
<div class="bg-blue-50 border-2 border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h3 class="font-semibold text-blue-900 mb-1">Requisitos</h3>
<ul class="text-sm text-blue-800 space-y-1">
<li>• Navegador Chrome/Edge (basado en Chromium)</li>
<li>• Ejecutar en <strong>HTTPS</strong> o <strong>localhost</strong></li>
<li>• Cable USB conectado al ESP8266</li>
</ul>
</div>
</div>
</div>
<!-- Drivers -->
<div class="bg-amber-50 border-2 border-amber-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="flex-1">
<h3 class="font-semibold text-amber-900 mb-2">Drivers Requeridos</h3>
<p class="text-sm text-amber-800 mb-3">Si tu ESP usa chip <strong>CH340</strong> (común en clones), instala el driver:</p>
<a href="http://www.wch.cn/downloads/CH341SER_EXE.html" target="_blank" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-amber-600 text-white hover:bg-amber-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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Descargar Driver CH340
</a>
<p class="text-xs text-amber-700 mt-2">
Para NodeMCU originales (CP2102):
<a class="font-medium underline hover:text-amber-900" href="https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers" target="_blank">Silicon Labs</a>
</p>
</div>
</div>
</div>
<!-- Info Firmware -->
<div class="bg-slate-50 border-2 border-slate-200 rounded-lg p-4">
<h3 class="font-semibold text-slate-900 mb-2">Firmware</h3>
<p class="text-sm text-slate-600 mb-2">Se usará el archivo definido en <code class="px-2 py-0.5 bg-slate-200 rounded text-xs font-mono">/assets/flash/esp8266-manifest.json</code></p>
<div id="otaFirmwareInfo_usb" class="text-xs text-slate-500">Cargando manifest...</div>
</div>
<!-- Botón de flasheo -->
<div class="flex flex-col items-center gap-3 py-4">
<esp-web-install-button id="installBtn" manifest="/assets/flash/esp8266-manifest.json"></esp-web-install-button>
<p class="text-xs text-slate-500 text-center max-w-md">
Si el botón aparece deshabilitado, verifica el navegador y el contexto seguro (HTTPS/localhost)
</p>
</div>
</div>
</div>
<!-- OTA (Over-The-Air) -->
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
OTA (Over-The-Air)
</h2>
<p class="text-xs text-slate-600 mt-1">Actualiza el firmware remotamente sin cables</p>
</div>
<div class="p-6 space-y-4">
<!-- Info OTA -->
<div class="bg-emerald-50 border-2 border-emerald-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-emerald-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h3 class="font-semibold text-emerald-900 mb-1">Cómo funciona</h3>
<p class="text-sm text-emerald-800">
El dispositivo descargará el firmware desde la URL especificada y se actualizará automáticamente.
Asegúrate de que la URL sea accesible desde la red del dispositivo.
</p>
</div>
</div>
</div>
<!-- Selector de dispositivo -->
<div>
<label for="otaChipidSelect" class="block text-sm font-semibold text-slate-700 mb-2">
Dispositivo
</label>
<select id="otaChipidSelect" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all">
<option value="" disabled selected>Cargando dispositivos...</option>
</select>
<p class="text-xs text-slate-500 mt-2" id="otaOnlineHint">
Solo se muestran dispositivos <strong>online</strong> que <strong>necesitan actualización</strong> (se ocultan los que ya tienen FW OK)
</p>
<div id="otaDeviceStatus" class="mt-3 inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-full bg-slate-100 text-slate-700 border border-slate-200">
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
Selecciona un dispositivo
</div>
</div>
<!-- Info Firmware OTA -->
<div class="bg-slate-50 border-2 border-slate-200 rounded-lg p-4">
<h3 class="font-semibold text-slate-900 mb-2">Firmware</h3>
<p class="text-sm text-slate-600 mb-2">Se usará el archivo definido en <code class="px-2 py-0.5 bg-slate-200 rounded text-xs font-mono">/assets/flash/esp8266-manifest.json</code></p>
<div id="otaFirmwareInfo_ota" class="text-xs text-slate-500">Cargando manifest...</div>
</div>
<!-- Botones de acción -->
<div class="flex flex-wrap items-center gap-3 pt-2">
<button id="btnStartOTA" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm 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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Iniciar OTA
</button>
<button id="btnRebootAfterOTA" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-slate-700 hover:bg-slate-50 border-2 border-slate-200 transition-all" title="Reiniciar dispositivo después de la actualización">
<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 Después
</button>
</div>
<!-- Indicador de progreso OTA -->
<div id="otaProgress" class="hidden mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<svg class="animate-spin h-6 w-6 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="flex-1">
<p id="otaProgressText" class="text-sm font-semibold text-blue-900">Procesando actualización OTA...</p>
<p class="text-xs text-blue-700 mt-1">El dispositivo descargará e instalará el firmware. Esto puede tomar hasta 3 minutos.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ESP Web Tools: botón de instalación/flasheo -->
<script type="module" src="https://unpkg.com/esp-web-tools@latest/dist/web/install-button.js?module"></script>
<script>
// OTA logic
(function(){
const MANIFEST_URL = '/assets/flash/esp8266-manifest.json';
let firmwareUrl = '';
let firmwareVersion = '';
let lastOtaChipid = '';
let otaTimeoutId = null;
const OTA_INACTIVITY_TIMEOUT_MS = 300000;
// Datos de dispositivos
let dispositivos = {}; // chipid -> {chipid, tag, nombre, ...}
let dispositivosLoadError = false;
const $chipSel = document.getElementById('otaChipidSelect');
const $btn = document.getElementById('btnStartOTA');
const $reboot = document.getElementById('btnRebootAfterOTA');
const $fwInfo_usb = document.getElementById('otaFirmwareInfo_usb');
const $fwInfo_ota = document.getElementById('otaFirmwareInfo_ota');
const $otaProgress = document.getElementById('otaProgress');
const $otaProgressText = document.getElementById('otaProgressText');
const $otaDeviceStatus = document.getElementById('otaDeviceStatus');
if (!$btn) return;
try { if (window.applyIcons) window.applyIcons(document); } catch {}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[ch]));
}
function normalizeChip(value) {
return String(value || '').trim().toUpperCase();
}
function findDevice(chipid) {
const normalized = normalizeChip(chipid);
const key = Object.keys(dispositivos || {}).find(item => normalizeChip(item) === normalized || normalizeChip(dispositivos[item]?.chipid) === normalized);
return key ? dispositivos[key] : null;
}
function setDeviceOnline(chipid, online) {
const normalized = normalizeChip(chipid);
const key = Object.keys(dispositivos || {}).find(item => normalizeChip(item) === normalized || normalizeChip(dispositivos[item]?.chipid) === normalized);
if (key) dispositivos[key].online = !!online;
}
function updateSelectedDeviceStatus() {
if (!$otaDeviceStatus) return;
const selectedChipid = normalizeChip($chipSel?.value);
if (!selectedChipid) {
$otaDeviceStatus.className = 'mt-3 inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-full bg-slate-100 text-slate-700 border border-slate-200';
$otaDeviceStatus.innerHTML = '<span class="w-2 h-2 rounded-full bg-slate-400"></span> Selecciona un dispositivo';
return;
}
const device = findDevice(selectedChipid);
const online = device && (device.online === true || String(device.online || '').toLowerCase() === 'online');
$otaDeviceStatus.className = online
? 'mt-3 inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-full bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'mt-3 inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-full bg-red-100 text-red-700 border border-red-200';
$otaDeviceStatus.innerHTML = online
? '<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Online ahora'
: '<span class="w-2 h-2 rounded-full bg-red-500"></span> Offline ahora';
}
function clearOtaTimeout() {
if (otaTimeoutId) clearTimeout(otaTimeoutId);
otaTimeoutId = null;
}
function armOtaTimeout() {
clearOtaTimeout();
otaTimeoutId = setTimeout(function(){
if ($otaProgressText) $otaProgressText.textContent = 'OTA sin actividad reciente. Verifica el estado del dispositivo.';
try { mostrarToast('No se detecto actividad OTA reciente. Verifica manualmente.', { type: 'warning' }); } catch{}
lastOtaChipid = '';
if ($btn) $btn.disabled = false;
}, OTA_INACTIVITY_TIMEOUT_MS);
}
// Cargar manifest para obtener la URL del .bin
function cargarManifest() {
if ($fwInfo_ota) $fwInfo_ota.textContent = 'Cargando manifest...';
if ($fwInfo_usb) $fwInfo_usb.textContent = 'Cargando manifest...';
return fetch(MANIFEST_URL, { cache: 'no-store' })
.then(r => r.json())
.then(json => {
try {
firmwareVersion = (typeof json.version === 'string') ? json.version : '';
const builds = Array.isArray(json.builds) ? json.builds : [];
const first = builds[0];
const parts = Array.isArray(first?.parts) ? first.parts : [];
const part = parts.find(p => typeof p?.path === 'string') || null;
if (part) {
const base = new URL(MANIFEST_URL, window.location.origin);
firmwareUrl = new URL(part.path, base).href;
const infoText = firmwareVersion ? `Firmware: ${firmwareUrl} (v${firmwareVersion})` : `Firmware: ${firmwareUrl}`;
if ($fwInfo_ota) $fwInfo_ota.textContent = infoText;
if ($fwInfo_usb) $fwInfo_usb.textContent = infoText;
return;
}
} catch {}
firmwareUrl = '';
if ($fwInfo_ota) $fwInfo_ota.textContent = 'No se pudo determinar el firmware desde el manifest.';
if ($fwInfo_usb) $fwInfo_usb.textContent = 'No se pudo determinar el firmware desde el manifest.';
})
.catch(() => {
firmwareUrl = '';
if ($fwInfo_ota) $fwInfo_ota.textContent = 'No se pudo cargar el manifest.';
if ($fwInfo_usb) $fwInfo_usb.textContent = 'No se pudo cargar el manifest.';
});
}
// Cargar dispositivos
function cargarDispositivos() {
return fetch('/api/get_dispositivos', { credentials: 'same-origin' })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
dispositivosLoadError = false;
dispositivos = data || {};
})
.catch(() => {
dispositivosLoadError = true;
dispositivos = {};
try { mostrarToast('No se pudo cargar la lista de dispositivos.', { type: 'danger' }); } catch{}
});
}
function renderSelect() {
if (!$chipSel) return;
if (dispositivosLoadError) {
$chipSel.innerHTML = '<option value="" disabled selected>Error al cargar dispositivos</option>';
return;
}
const opciones = [];
let totalOnline = 0;
let filtradosPorFW = 0;
for (const chip in dispositivos) {
const d = dispositivos[chip];
const isOnline = d.online === true || String(d.online || '').toLowerCase() === 'online';
if (isOnline) {
totalOnline++;
// Filtrar dispositivos que ya tienen el firmware actualizado
const deviceFW = (d.firmware || '').trim();
const isFWUpdated = firmwareVersion && deviceFW && deviceFW === firmwareVersion;
if (!isFWUpdated) {
const fwInfo = deviceFW ? ` - FW: ${deviceFW}` : '';
const label = `${d.tag || chip} (${d.chipid || chip})${fwInfo}`;
opciones.push(`<option value="${escapeHtml(d.chipid || chip)}">${escapeHtml(label)}</option>`);
} else {
filtradosPorFW++;
}
}
}
if (opciones.length === 0) {
if (totalOnline === 0) {
$chipSel.innerHTML = '<option value="" disabled selected>No hay dispositivos online</option>';
} else {
$chipSel.innerHTML = '<option value="" disabled selected>Todos los dispositivos tienen FW actualizado</option>';
}
} else {
const hint = filtradosPorFW > 0 ? ` (${filtradosPorFW} con FW OK ocultos)` : '';
$chipSel.innerHTML = `<option value="" disabled selected>Seleccione un dispositivo${hint}</option>` + opciones.join('');
}
// Preseleccionar desde query si existe y está online
try {
const qp = new URLSearchParams(window.location.search);
const qchip = normalizeChip(qp.get('chipid'));
if (qchip && opciones.length) {
const opt = Array.from($chipSel.options).find(o => normalizeChip(o.value) === qchip);
if (opt) $chipSel.value = opt.value;
}
} catch {}
updateSelectedDeviceStatus();
}
Promise.all([cargarManifest(), cargarDispositivos()]).then(renderSelect);
$chipSel?.addEventListener('change', updateSelectedDeviceStatus);
if (window.mqttRealtime) {
window.mqttRealtime.onMessage(function(evt) {
if (evt.type === 'state') {
setDeviceOnline(evt.chipid, evt.estado === 'online');
updateSelectedDeviceStatus();
}
if (evt.type !== 'ota' || normalizeChip(evt.chipid) !== normalizeChip(lastOtaChipid)) return;
const status = String(evt.status || '').trim();
if ($otaProgress) $otaProgress.classList.remove('hidden');
if ($otaProgressText) $otaProgressText.textContent = `OTA: ${status}`;
armOtaTimeout();
if (/^(success|failed|no_updates|error:)/i.test(status)) {
clearOtaTimeout();
lastOtaChipid = '';
if ($btn) $btn.disabled = false;
if (status === 'success') {
try { mostrarToast('OTA completada. El dispositivo se reiniciara.', { type: 'success' }); } catch{}
} else {
try { mostrarToast(`OTA finalizo con estado: ${status}`, { type: status.indexOf('error:') === 0 || status === 'failed' ? 'danger' : 'warning' }); } catch{}
}
}
});
}
$btn.addEventListener('click', function(){
const chipid = ($chipSel?.value || '').trim();
const fw = firmwareUrl;
if (!chipid) { try { mostrarToast('Selecciona un dispositivo online', { type: 'danger' }); } catch{} return; }
if (!fw || !fw.endsWith('.bin')) { try { mostrarToast('El manifest no define un .bin válido', { type: 'danger' }); } catch{} return; }
const topic = `dispositivo/${chipid}/comando/ota`;
const payload = { url: fw };
$btn.disabled = true;
clearOtaTimeout();
// Mostrar progreso
if ($otaProgress) $otaProgress.classList.remove('hidden');
if ($otaProgressText) $otaProgressText.textContent = 'Enviando comando OTA al dispositivo...';
lastOtaChipid = chipid;
const publisher = api.postJSON('/api/mqtt_publish', { topic, payload, qos: 0, retain: false });
publisher
.then(function(){
try { mostrarToast('Comando OTA enviado'); } catch{}
if ($otaProgressText) $otaProgressText.textContent = 'Esperando que el dispositivo descargue e instale el firmware...';
armOtaTimeout();
/*
if ($otaProgress) $otaProgress.classList.add('hidden');
try { mostrarToast('⏱️ No se detectó respuesta del dispositivo. Verifica manualmente.', { type: 'warning' }); } catch{}
lastOtaChipid = '';
$btn.disabled = false;
}, 180000); // 3 minutos
*/
})
.catch(function(err){
const msg = err.data?.message || err.data?.msg || err.message || 'Error al enviar OTA';
try { mostrarToast(msg, { type: 'danger' }); } catch{}
if ($otaProgress) $otaProgress.classList.add('hidden');
lastOtaChipid = '';
$btn.disabled = false;
});
});
$reboot?.addEventListener('click', function(){
const chipid = ($chipSel?.value || '').trim();
if (!chipid) { try { mostrarToast('Selecciona un dispositivo', { type: 'danger' }); } catch{} 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(){ try { mostrarToast('Comando de reinicio enviado'); } catch{} })
.catch(function(){ try { mostrarToast('No se pudo enviar reinicio', { type: 'danger' }); } catch{} });
});
})();
</script>
<?php
$content = ob_get_clean();
if (!defined('RENDER_PARTIAL')) {
include __DIR__ . '/../layouts/layout.php';
}