478 lines
38 KiB
HTML
478 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<link rel="icon" href="data:,">
|
||
<title>Cloud Hub — ESP32 Solar Tracker</title>
|
||
<script src="/static/chart.umd.min.js"></script>
|
||
<style>
|
||
*{box-sizing:border-box}
|
||
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;margin:0;padding:14px;background:#0b1120;color:#e2e8f0}
|
||
header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #1e293b;padding-bottom:12px;margin-bottom:14px}
|
||
h1{margin:0;font-size:20px;color:#38bdf8;display:flex;align-items:center;gap:8px}
|
||
h2{font-size:14px;margin:0 0 10px;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
||
.muted{color:#64748b;font-size:12px}
|
||
.badge{padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700}
|
||
.badge-online{background:#14532d;color:#4ade80;border:1px solid #22c55e}
|
||
.badge-offline{background:#7f1d1d;color:#f87171;border:1px solid #ef4444}
|
||
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:14px}
|
||
.tile{background:#111a2e;border:1px solid #1e293b;border-radius:8px;padding:10px 12px}
|
||
.tile .tl{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.4px}
|
||
.tile .tv{font-size:22px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums}
|
||
.tile .tu{font-size:11px;color:#64748b}
|
||
.card{background:#111a2e;border:1px solid #1e293b;border-radius:8px;padding:14px}
|
||
.controlbar{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-bottom:14px}
|
||
.cb-group{display:flex;align-items:center;gap:6px}
|
||
.cb-label{font-size:11px;color:#94a3b8;text-transform:uppercase}
|
||
button{background:#0369a1;color:#fff;border:none;padding:6px 12px;border-radius:5px;font-weight:600;cursor:pointer;font-size:12px}
|
||
button:hover{filter:brightness(1.15)}
|
||
button:disabled,input:disabled,select:disabled{opacity:.45;cursor:not-allowed}
|
||
.fleetbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
|
||
.fleetbar select{min-width:220px;max-width:100%}
|
||
button.btn-active{background:#38bdf8;color:#0b1120}
|
||
.btn-danger{background:#b91c1c}.btn-warning{background:#b45309}.btn-secondary{background:#334155}
|
||
.mini{padding:2px 8px;font-size:11px}
|
||
input[type=number]{background:#0b1120;border:1px solid #334155;color:#e2e8f0;padding:5px 8px;border-radius:5px;width:74px}
|
||
select{background:#0b1120;border:1px solid #334155;color:#e2e8f0;padding:5px 8px;border-radius:5px}
|
||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||
.span2{grid-column:1/-1}
|
||
.cwrap{position:relative;height:230px}
|
||
.unit{font-weight:400;font-size:11px;color:#64748b}
|
||
#log{height:230px;overflow-y:auto;background:#020617;color:#86efac;font-family:ui-monospace,Consolas,monospace;font-size:12px;padding:10px;border-radius:6px;border:1px solid #1e293b}
|
||
#log div{white-space:pre-wrap;word-break:break-word;line-height:1.5}
|
||
table{width:100%;border-collapse:collapse;font-size:12px;margin-top:6px}
|
||
th,td{text-align:left;padding:5px 8px;border-bottom:1px solid #1e293b}
|
||
th{color:#94a3b8}
|
||
.input-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;font-size:12px}
|
||
.axis-controls{flex-wrap:wrap;gap:6px}.axis-controls>div{display:flex;align-items:center;flex-wrap:wrap;gap:4px}
|
||
.cdnwarn{background:#7c2d12;border:1px solid #ea580c;color:#fde68a;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:13px}
|
||
@media(max-width:900px){.tiles{grid-template-columns:repeat(3,1fr)}.grid{grid-template-columns:1fr}header{flex-wrap:wrap;gap:10px}.input-row{flex-wrap:wrap;gap:6px}.cb-group{flex-wrap:wrap}}
|
||
@media(max-width:560px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))!important}.fleetbar select{width:100%;min-width:0}.input-row>div{max-width:100%;display:flex;flex-wrap:wrap;gap:4px}input[type=file]{max-width:100%}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>☀️ ESP32 Solar Tracker — Cloud Hub</h1>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<span id="lastUpdate" class="muted">--</span>
|
||
<span id="trackerStatus" class="badge badge-offline">ESP32 OFFLINE</span>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="card fleetbar" aria-label="Tarjetas ESP32">
|
||
<label for="deviceSel">Tarjeta activa</label>
|
||
<select id="deviceSel" onchange="selectDevice(this.value)"><option value="">Selecciona una tarjeta</option></select>
|
||
<span id="fleetNote" class="muted" role="status">Cargando tarjetas...</span>
|
||
<span id="deviceInfo" class="muted"></span>
|
||
</section>
|
||
|
||
<section class="tiles">
|
||
<div class="tile"><div class="tl">Irradiancia</div><div class="tv" id="t_gpoa">–</div><div class="tu">W/m²</div></div>
|
||
<div class="tile"><div class="tl">Potencia P_CPV</div><div class="tv" id="t_power">–</div><div class="tu">mW</div></div>
|
||
<div class="tile"><div class="tl">Voc</div><div class="tv" id="t_voc">–</div><div class="tu">V</div></div>
|
||
<div class="tile"><div class="tl">Isc</div><div class="tv" id="t_isc">–</div><div class="tu">mA</div></div>
|
||
<div class="tile"><div class="tl">Azimut</div><div class="tv" id="t_az">–</div><div class="tu">grados</div></div>
|
||
<div class="tile"><div class="tl">Elevación</div><div class="tv" id="t_el">–</div><div class="tu">grados</div></div>
|
||
</section>
|
||
|
||
<section class="card controlbar">
|
||
<div class="cb-group">
|
||
<span class="cb-label">Ventana</span>
|
||
<button data-win="300" onclick="setWindow(300,this)">5 min</button>
|
||
<button data-win="900" class="btn-active" onclick="setWindow(900,this)">15 min</button>
|
||
<button data-win="1800" onclick="setWindow(1800,this)">30 min</button>
|
||
<button data-win="3600" onclick="setWindow(3600,this)">1 hora</button>
|
||
<button data-win="7200" onclick="setWindow(7200,this)">2 horas</button>
|
||
<button data-win="14400" onclick="setWindow(14400,this)">4 horas</button>
|
||
</div>
|
||
<div class="cb-group">
|
||
<span class="cb-label">Piranómetro (mV/W/m²)</span>
|
||
<input type="number" id="calPyrano" step="0.05" min="0.01" placeholder="0.2">
|
||
<button class="btn-warning" onclick="applyCalib()">Aplicar calibración</button>
|
||
<span id="calNote" class="muted"></span>
|
||
</div>
|
||
<div class="cb-group">
|
||
<span class="cb-label">PCB</span>
|
||
<select id="pcbSel" onchange="applyPcb()">
|
||
<option value="V1">V1 (75 mΩ)</option>
|
||
<option value="V2">V2 (260 mΩ)</option>
|
||
</select>
|
||
<span id="pcbNote" class="muted"></span>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="cdnWarn" class="cdnwarn" style="display:none">⚠️ Chart.js no cargó (¿sin internet en este equipo?). Los valores numéricos y el registro siguen funcionando.</div>
|
||
<main class="grid">
|
||
<section class="card chart"><h2>Irradiancia <span class="unit">G_POA · W/m²</span></h2><div class="cwrap"><canvas id="ch_gpoa"></canvas></div></section>
|
||
<section class="card chart"><h2>Potencia CPV <span class="unit">P_CPV · mW</span></h2><div class="cwrap"><canvas id="ch_power"></canvas></div></section>
|
||
<section class="card chart"><h2>Posición solar <span class="unit">azimut & elevación · grados</span></h2><div class="cwrap"><canvas id="ch_pos"></canvas></div></section>
|
||
<section class="card chart"><h2>Señales del CPV <span class="unit">Voc (V) · Isc (mA)</span></h2><div class="cwrap"><canvas id="ch_pv"></canvas></div></section>
|
||
<section class="card chart span2"><h2>Trayectoria de la cabeza <span class="unit">X–Y (pasos) · X <span id="posX" style="color:#38bdf8">0</span> · Y <span id="posY" style="color:#38bdf8">0</span></span></h2><div class="cwrap"><canvas id="ch_traj"></canvas></div></section>
|
||
|
||
<section class="card span2">
|
||
<h2>Telemetría del control <span class="unit">promedios cada 2 s · LPF · Δsteps</span></h2>
|
||
<div class="tiles" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
||
<div class="tile"><div class="tl">Δsteps / periodo</div><div class="tv" id="tel_delta">–</div><div class="tu">pasos</div></div>
|
||
<div class="tile"><div class="tl">Total steps</div><div class="tv" id="tel_steps">–</div><div class="tu">pasos</div></div>
|
||
<div class="tile"><div class="tl">LPF (filtrado)</div><div class="tv" id="tel_lpf">–</div><div class="tu">counts</div></div>
|
||
<div class="tile"><div class="tl">avgC (crudo)</div><div class="tv" id="tel_avgc">–</div><div class="tu">counts</div></div>
|
||
</div>
|
||
<div class="grid" style="gap:14px">
|
||
<div><h2>Señal filtrada <span class="unit">LPF vs avgC (counts)</span></h2><div class="cwrap"><canvas id="ch_tel_sig"></canvas></div></div>
|
||
<div><h2>Actividad del motor <span class="unit">Δsteps / periodo</span></h2><div class="cwrap"><canvas id="ch_tel_steps"></canvas></div></div>
|
||
</div>
|
||
<div id="telEmpty" class="muted" style="display:none;margin-top:10px">Sin telemetría todavía: el firmware debe emitir líneas «TEL:» (build con telemetría activa). Con firmware viejo este panel permanece vacío.</div>
|
||
</section>
|
||
<section class="card">
|
||
<h2>Registro en tiempo real <button class="mini btn-secondary" onclick="loadLogs()">↻ refrescar</button></h2>
|
||
<div id="log"><div style="color:#64748b">Esperando datos del ESP32…</div></div>
|
||
</section>
|
||
<section class="card">
|
||
<h2>Comandos remotos</h2>
|
||
<div style="margin-bottom:12px">
|
||
<button onclick="sendRaw('CMD:2')">Track solar</button>
|
||
<button onclick="sendRaw('CMD:3')">Fine-tune</button>
|
||
<button class="btn-danger" onclick="sendRaw('CMD:4')">Homing</button>
|
||
<button onclick="sendRaw('CMD:5')">Centro</button>
|
||
<button class="btn-secondary" onclick="sendRaw('CMD:10')">Estado TMC</button>
|
||
</div>
|
||
|
||
<h2>Ajustes del tracker (WebSocket)</h2>
|
||
<div class="input-row"><label>Ir a coordenadas X,Y</label><div><input id="inPosX" type="number" placeholder="X"><input id="inPosY" type="number" placeholder="Y"><button onclick="sendCoords()">Enviar</button></div></div>
|
||
<div class="input-row"><label>ErrMax (counts)</label><div><input id="inErr" type="number" placeholder="1000"><button onclick="sendParam('errMax','inErr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>MinErrMax (counts)</label><div><input id="inMinErr" type="number" placeholder="193"><button onclick="sendParam('minErrMax','inMinErr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Delay (s)</label><div><input id="inDelay" type="number" placeholder="2"><button onclick="sendParam('delay','inDelay')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>LPF Beta (0.01-1.0)</label><div><input id="inLPFBeta" type="number" step="0.05" placeholder="0.70"><button onclick="sendParam('lpfBeta','inLPFBeta')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Small Threshold</label><div><input id="inSmallThr" type="number" placeholder="4500"><button onclick="sendParam('smallThreshold','inSmallThr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Abrupt Threshold</label><div><input id="inAbruptThr" type="number" placeholder="15000"><button onclick="sendParam('abruptThreshold','inAbruptThr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Reset Threshold</label><div><input id="inResetThr" type="number" placeholder="25000"><button onclick="sendParam('resetThreshold','inResetThr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Peak Proximity</label><div><input id="inPeakProx" type="number" step="0.005" placeholder="0.30"><button onclick="sendParam('peakProximity','inPeakProx')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Cicle Max</label><div><input id="inCicleMax" type="number" placeholder="2"><button onclick="sendParam('cicleMax','inCicleMax')">Aplicar</button></div></div>
|
||
<div class="input-row axis-controls"><label>Solicitar relación X:Y (base × factor)</label><div><input id="axisXFactor" aria-label="Factor de X" type="number" min="1" max="8" step="1" value="3"><span>:</span><input id="axisYFactor" aria-label="Factor de Y" type="number" min="1" max="8" step="1" value="1"><button onclick="sendAxisFactors()">Aplicar factores</button></div></div>
|
||
<div class="muted" id="axisSchedule" role="status" aria-live="polite">Sin confirmación de la tarjeta. Ejemplo 3:1: base 4 da 12 X y luego 4 Y; base 1 da 3 X y luego 1 Y.</div>
|
||
<div class="input-row"><label>Min Irrad (W/m²)</label><div><input id="inMinIrr" type="number" step="1" placeholder="10"><button onclick="sendParam('minIrradiance','inMinIrr')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Az-dev (grados)</label><div><input id="inAz" type="number" step="0.5"><button onclick="sendParam('az','inAz')">Aplicar</button></div></div>
|
||
<div class="input-row"><label>Tilt (grados)</label><div><input id="inTilt" type="number" step="0.5"><button onclick="sendParam('tilt','inTilt')">Aplicar</button></div></div>
|
||
|
||
<h2 style="margin-top:14px">Archivos de log</h2>
|
||
<table><thead><tr><th>Fecha</th><th>Tamaño</th><th></th></tr></thead><tbody id="logsTableBody"><tr><td colspan="3" class="muted" style="text-align:center">Cargando…</td></tr></tbody></table>
|
||
</section>
|
||
<section class="card">
|
||
<h2>Firmware / OTA <button class="mini btn-secondary" onclick="loadOta()">↻ refrescar</button></h2>
|
||
<div class="input-row"><label>Tarjeta destino</label><strong id="otaTarget">Selecciona una tarjeta</strong></div>
|
||
<p class="muted">La imagen disponible es compartida. Subirla no actualiza ninguna tarjeta; el envío se confirma para el destino seleccionado.</p>
|
||
<div class="input-row"><label>Versión en el dispositivo</label><span id="otaDevice" class="muted">--</span></div>
|
||
<div class="input-row"><label>Versión disponible</label><span id="otaAvail" class="muted">sin imagen</span></div>
|
||
<div id="otaMeta" class="muted" style="margin-bottom:10px"></div>
|
||
<div id="otaStatus" class="muted" style="margin-bottom:10px"></div>
|
||
<div class="input-row"><label>Nueva firmware</label><div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"><input type="file" id="otaFile" accept=".bin,application/octet-stream"><input id="otaVersion" type="text" placeholder="vX.Y.Z" style="width:96px"></div></div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
|
||
<button class="btn-warning" onclick="uploadOta()">Subir firmware</button>
|
||
<button id="otaPush" class="btn-danger" onclick="pushOta()" disabled>Actualizar esta tarjeta</button>
|
||
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#94a3b8;cursor:pointer"><input type="checkbox" id="otaAuto"> auto-actualizar al conectar</label>
|
||
</div>
|
||
<button class="btn-secondary mini" onclick="saveOtaAuto()">Guardar auto-update</button>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
"use strict";
|
||
var ws = null, WIN = 900, WIN_MAX = 14400, MAX_POINTS = 15000;
|
||
var samples = [], telSamples = [], cfg = null, charts = {}, chartsOK = false;
|
||
var activeDevice = '', deviceGeneration = 0, deviceReady = false, fleet = [];
|
||
var axisConfirmed = null, axisRequested = null, axisOnline = false;
|
||
var selectionController = new AbortController(), reconnectTimer = null, fleetLoading = false;
|
||
function $(id){ return document.getElementById(id); }
|
||
function selection(){ return {id:activeDevice, generation:deviceGeneration, signal:selectionController.signal}; }
|
||
function isCurrent(ctx){ return ctx.id===activeDevice && ctx.generation===deviceGeneration; }
|
||
function deviceLabel(id){ var d=fleet.find(function(d){return d.device_id===id;}); return d ? (d.alias||id)+' ['+id+']' : id; }
|
||
function deviceUrl(id, path){ return '/api/devices/'+encodeURIComponent(id)+path; }
|
||
async function deviceRequest(path, options, ctx){
|
||
ctx=ctx||selection();
|
||
if(!ctx.id || !isCurrent(ctx)) throw new DOMException('Seleccion obsoleta','AbortError');
|
||
var response=await fetch(deviceUrl(ctx.id,path), Object.assign({},options||{}, {signal:ctx.signal}));
|
||
var data=await response.json();
|
||
if(!isCurrent(ctx)) throw new DOMException('Seleccion obsoleta','AbortError');
|
||
if(!response.ok) throw new Error(data.detail||'Error HTTP '+response.status);
|
||
return data;
|
||
}
|
||
function actionError(ctx, id, error){ if(isCurrent(ctx) && error.name!=='AbortError') setT(id,'Error: '+error.message); }
|
||
function setDeviceReady(ready){
|
||
deviceReady=ready;
|
||
document.querySelectorAll('main button,main input,.controlbar input,.controlbar select,.controlbar button:not([data-win])').forEach(function(el){el.disabled=!ready;});
|
||
renderOta();
|
||
}
|
||
function resetDevicePanel(){
|
||
samples=[]; telSamples=[]; cfg=null;
|
||
ota={device:null,available:null,auto:false,url:null,connected:false};
|
||
['t_gpoa','t_power','t_voc','t_isc','t_az','t_el','tel_delta','tel_steps','tel_lpf','tel_avgc','posX','posY','lastUpdate'].forEach(function(id){setT(id,'--');});
|
||
['calNote','pcbNote','otaMeta','otaStatus','deviceInfo'].forEach(function(id){setT(id,'');});
|
||
axisConfirmed=null; axisRequested=null; axisOnline=false; renderAxisSchedule();
|
||
document.querySelectorAll('main input,.controlbar input').forEach(function(el){if(el.type!=='checkbox') el.value='';});
|
||
$('axisXFactor').value='3'; $('axisYFactor').value='1';
|
||
$('pcbSel').value='V1'; $('log').textContent='Esperando datos de la tarjeta seleccionada...';
|
||
$('logsTableBody').textContent='';
|
||
['gpoa','power','pos','pv','tel_sig','tel_steps'].forEach(function(k){if(charts[k]){charts[k].options.scales.x.min=0;charts[k].options.scales.x.max=1;}});
|
||
setStatus(false); renderAll(); setDeviceReady(false);
|
||
setT('otaTarget',activeDevice ? deviceLabel(activeDevice) : 'Selecciona una tarjeta');
|
||
}
|
||
function selectDevice(id){
|
||
if(id && !fleet.some(function(d){return d.device_id===id;})) return;
|
||
clearTimeout(reconnectTimer); selectionController.abort(); selectionController=new AbortController();
|
||
deviceGeneration++; activeDevice=id; $('deviceSel').value=id;
|
||
if(ws){var old=ws; ws=null; old.close();}
|
||
try{localStorage.setItem('tracker-device',id);}catch(e){}
|
||
resetDevicePanel();
|
||
if(id){ connect(); loadLogs(); loadOta(); }
|
||
}
|
||
async function refreshFleet(){
|
||
if(fleetLoading) return;
|
||
fleetLoading=true;
|
||
try{
|
||
var response=await fetch('/api/devices');
|
||
if(!response.ok) throw new Error('HTTP '+response.status);
|
||
var rows=await response.json();
|
||
if(!Array.isArray(rows)) throw new Error('Lista de tarjetas invalida');
|
||
fleet=rows;
|
||
var sel=$('deviceSel'); sel.textContent='';
|
||
var placeholder=document.createElement('option'); placeholder.value='';placeholder.textContent='Selecciona una tarjeta';sel.appendChild(placeholder);
|
||
rows.forEach(function(d){var opt=document.createElement('option');opt.value=d.device_id;opt.textContent=(d.alias||d.device_id)+' · '+d.device_id+' · '+(d.esp32_connected?'conectada':'desconectada');sel.appendChild(opt);});
|
||
if(activeDevice && !rows.some(function(d){return d.device_id===activeDevice;})) selectDevice('');
|
||
if(!activeDevice && deviceGeneration===0){
|
||
var saved='';try{saved=localStorage.getItem('tracker-device')||'';}catch(e){}
|
||
if(saved && rows.some(function(d){return d.device_id===saved;})) selectDevice(saved);
|
||
}
|
||
sel.value=activeDevice;
|
||
setT('fleetNote',rows.length ? rows.length+' tarjeta(s) registradas' : 'Sin tarjetas. Da de alta cada ID y token desde la CLI del hub.');
|
||
var current=rows.find(function(d){return d.device_id===activeDevice;});
|
||
if(current){
|
||
var seen=current.last_telemetry_at ? Date.parse(current.last_telemetry_at) : NaN;
|
||
setT('deviceInfo',current.esp32_connected ? (Date.now()-seen>30000 || !Number.isFinite(seen) ? 'Conectada, sin telemetria reciente' : 'Telemetria reciente') : 'Desconectada');
|
||
setT('otaTarget',deviceLabel(activeDevice));
|
||
}
|
||
}catch(e){setT('fleetNote','No se pudo consultar la flota: '+e.message);}
|
||
finally{fleetLoading=false;}
|
||
}
|
||
function fmtTime(ts){ if(ts==null) return ''; var d=new Date(ts*1000); function p(n){return (''+n).padStart(2,'0');} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
|
||
function lastTs(){ return samples.length ? samples[samples.length-1].ts : null; }
|
||
function trimSamples(){ if(samples.length>MAX_POINTS) samples.splice(0,samples.length-MAX_POINTS); var t = lastTs(); if(t==null) return; var cut = t - WIN_MAX; while(samples.length && samples[0].ts < cut) samples.shift(); }
|
||
function axisTime(){ return { type:'linear', min:0, max:1, ticks:{color:'#94a3b8', maxTicksLimit:8, callback:function(v){ return fmtTime(v); }}, grid:{color:'#1e293b'} }; }
|
||
|
||
function buildCharts(){
|
||
if(!window.Chart){ chartsOK=false; if($('cdnWarn')) $('cdnWarn').style.display='block'; return; }
|
||
chartsOK = true;
|
||
function baseScales(yTitle, y1Title){
|
||
var s = { x: axisTime(), y:{ title:{display:!!yTitle,color:'#94a3b8',text:yTitle}, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} } };
|
||
if(y1Title){ s.y1 = { position:'right', title:{display:true,color:'#22d3ee',text:y1Title}, ticks:{color:'#22d3ee'}, grid:{drawOnChartArea:false} }; }
|
||
return s;
|
||
}
|
||
function line(cvId, datasets, yT, y1T){
|
||
return new Chart($(cvId), { type:'line', data:{ datasets:datasets },
|
||
options:{ responsive:true, maintainAspectRatio:false, animation:false,
|
||
interaction:{mode:'nearest',intersect:false},
|
||
scales: baseScales(yT, y1T),
|
||
plugins:{ legend:{ labels:{color:'#e2e8f0', boxWidth:12} } } } });
|
||
}
|
||
charts.gpoa = line('ch_gpoa', [ { label:'G_POA', data:[], borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,0.15)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'W/m²');
|
||
charts.power = line('ch_power', [ { label:'P_CPV', data:[], borderColor:'#ef4444', backgroundColor:'rgba(239,68,68,0.15)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'mW');
|
||
charts.pos = line('ch_pos', [ { label:'Azimut', data:[], borderColor:'#38bdf8', borderWidth:1.6, pointRadius:0, tension:0.25 }, { label:'Elevación', data:[], borderColor:'#4ade80', borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'grados');
|
||
charts.pv = line('ch_pv', [ { label:'Voc (V)', data:[], borderColor:'#a78bfa', borderWidth:1.4, pointRadius:0, yAxisID:'y' }, { label:'Isc (mA)', data:[], borderColor:'#22d3ee', borderWidth:1.4, pointRadius:0, yAxisID:'y1' } ], 'V', 'mA');
|
||
charts.tel_sig = line('ch_tel_sig', [ { label:'LPF (filtrado)', data:[], borderColor:'#22d3ee', backgroundColor:'rgba(34,211,238,0.12)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 }, { label:'avgC (crudo)', data:[], borderColor:'#f59e0b', borderWidth:1.2, borderDash:[4,3], pointRadius:0, tension:0.25 } ], 'counts');
|
||
charts.tel_steps = line('ch_tel_steps', [ { label:'Δsteps / periodo', data:[], borderColor:'#4ade80', backgroundColor:'rgba(74,222,128,0.15)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'pasos');
|
||
charts.traj = new Chart($('ch_traj'), { type:'scatter', data:{ datasets:[ { label:'X–Y', data:[], backgroundColor:'#38bdf8', pointRadius:1.5 } ] },
|
||
options:{ responsive:true, maintainAspectRatio:false, animation:false,
|
||
scales:{ x:{ title:{display:true,color:'#94a3b8',text:'X (pasos)'}, min:0, max:106000, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} },
|
||
y:{ title:{display:true,color:'#94a3b8',text:'Y (pasos)'}, min:0, max:104000, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} } },
|
||
plugins:{ legend:{display:false} } } });
|
||
}
|
||
function setT(id, v){ var e=$(id); if(e && v!=null) e.textContent = v; }
|
||
function renderAll(){
|
||
var t = lastTs();
|
||
var s = (t!=null) ? samples[samples.length-1] : null;
|
||
if(chartsOK){
|
||
if(charts.gpoa) charts.gpoa.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.gpoa};});
|
||
if(charts.power) charts.power.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.pcpv_mw};});
|
||
if(charts.pos){ charts.pos.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.az};}); charts.pos.data.datasets[1].data = samples.map(function(x){return {x:x.ts,y:x.el};}); }
|
||
if(charts.pv){ charts.pv.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.voc};}); charts.pv.data.datasets[1].data = samples.map(function(x){return {x:x.ts,y:x.isc_ma};}); }
|
||
if(charts.traj) charts.traj.data.datasets[0].data = samples.map(function(x){return {x:x.x,y:x.y};});
|
||
if(charts.tel_sig){ charts.tel_sig.data.datasets[0].data = telSamples.map(function(x){return {x:x.ts,y:x.lpf};}); charts.tel_sig.data.datasets[1].data = telSamples.map(function(x){return {x:x.ts,y:x.avg_c};}); }
|
||
if(charts.tel_steps){ charts.tel_steps.data.datasets[0].data = telSamples.map(function(x){return {x:x.ts,y:x.steps_delta};}); }
|
||
if(t!=null){ ['gpoa','power','pos','pv'].forEach(function(id){ if(charts[id]){ charts[id].options.scales.x.min = t-WIN; charts[id].options.scales.x.max = t; } }); }
|
||
var tt = lastTelTs();
|
||
if(tt!=null){ ['tel_sig','tel_steps'].forEach(function(id){ if(charts[id]){ charts[id].options.scales.x.min = tt-WIN; charts[id].options.scales.x.max = tt; } }); }
|
||
for(var k in charts){ charts[k].update('none'); }
|
||
}
|
||
if(s){ setT('t_gpoa',s.gpoa); setT('t_power',s.pcpv_mw); setT('t_voc',s.voc); setT('t_isc',s.isc_ma); setT('t_az',s.az); setT('t_el',s.el); if($('posX')) $('posX').textContent=s.x; if($('posY')) $('posY').textContent=s.y; }
|
||
var tt2 = lastTelTs();
|
||
var st = (tt2!=null) ? telSamples[telSamples.length-1] : null;
|
||
if(st){ setT('tel_delta', st.steps_delta); setT('tel_steps', st.steps); setT('tel_lpf', st.lpf); setT('tel_avgc', st.avg_c); }
|
||
if($('telEmpty')) $('telEmpty').style.display = st ? 'none' : 'block';
|
||
}
|
||
function pushSample(s){ samples.push(s); trimSamples(); if(lastTs()!==null){ $('lastUpdate').textContent = 'última: '+fmtTime(lastTs()); } renderAll(); }
|
||
function seedHistory(arr){ samples = (arr||[]).slice(-MAX_POINTS); trimSamples(); renderAll(); }
|
||
function lastTelTs(){ return telSamples.length ? telSamples[telSamples.length-1].ts : null; }
|
||
function trimTel(){ if(telSamples.length>MAX_POINTS) telSamples.splice(0,telSamples.length-MAX_POINTS); var t = lastTelTs(); if(t==null) return; var cut = t - WIN_MAX; while(telSamples.length && telSamples[0].ts < cut) telSamples.shift(); }
|
||
function pushTel(s){ telSamples.push(s); trimTel(); renderAll(); }
|
||
function seedTel(arr){ telSamples = (arr||[]).slice(-MAX_POINTS); trimTel(); renderAll(); }
|
||
|
||
function setStatus(on){ var b=$('trackerStatus'); if(!b) return; b.className='badge '+(on?'badge-online':'badge-offline'); b.textContent = on?'ESP32 ONLINE':'ESP32 OFFLINE'; }
|
||
function renderAxisSchedule(){
|
||
var text=axisConfirmed ? 'Confirmado por la tarjeta: '+axisConfirmed.x+':'+axisConfirmed.y+'; base '+axisConfirmed.base+' da '+axisConfirmed.x_budget+' X y luego '+axisConfirmed.y_budget+' Y.' : 'Sin confirmación de la tarjeta. Ejemplo 3:1: base 4 da 12 X y luego 4 Y; base 1 da 3 X y luego 1 Y.';
|
||
if(axisRequested){
|
||
text+=' Solicitud pendiente, no confirmada:';
|
||
if(axisRequested.x!=null) text+=' X='+axisRequested.x;
|
||
if(axisRequested.y!=null) text+=' Y='+axisRequested.y;
|
||
if(axisRequested.base!=null) text+=' base='+axisRequested.base;
|
||
text+='.';
|
||
}
|
||
setT('axisSchedule',text);
|
||
}
|
||
function appendLog(text){
|
||
var box=$('log'); if(!box) return;
|
||
if(box.firstChild && box.firstChild.style && box.firstChild.style.color==='rgb(100, 116, 139)') box.innerHTML='';
|
||
var d=document.createElement('div'); d.textContent = text.replace(/\n$/,''); box.appendChild(d);
|
||
while(box.children.length>400) box.removeChild(box.firstChild);
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
function onMsg(msg){
|
||
if(msg.indexOf('STATUS:')===0){ axisOnline=msg==='STATUS:ONLINE'; axisConfirmed=null; axisRequested=null; renderAxisSchedule(); setStatus(axisOnline); }
|
||
else if(msg.indexOf('CONFIG:')===0){ try{ var incoming=JSON.parse(msg.slice(7)); if(incoming.device_id!==activeDevice) return; cfg=incoming; if($('calPyrano')) $('calPyrano').value=cfg.pyrano; if($('calNote')) $('calNote').textContent='mV/W/m² = '+cfg.pyrano+' · az-dev = '+cfg.azdev; if(cfg.pcb_version && $('pcbSel') && $('pcbSel').value!==cfg.pcb_version) $('pcbSel').value=cfg.pcb_version; setDeviceReady(true); }catch(e){} }
|
||
else if(msg.indexOf('HISTORY:')===0){ try{ var history=JSON.parse(msg.slice(8)); if(history.every(function(p){return p.device_id===activeDevice;})) seedHistory(history); }catch(e){} }
|
||
else if(msg.indexOf('DATA:')===0){ try{ var point=JSON.parse(msg.slice(5)); if(point.device_id===activeDevice) pushSample(point); }catch(e){} }
|
||
else if(msg.indexOf('TEL:')===0){ try{ var point=JSON.parse(msg.slice(4)); if(point.device_id===activeDevice) pushTel(point); }catch(e){} }
|
||
else if(msg.indexOf('TEL_HISTORY:')===0){ try{ var history=JSON.parse(msg.slice(12)); if(history.every(function(p){return p.device_id===activeDevice;})) seedTel(history); }catch(e){} }
|
||
else if(msg.indexOf('LOG:')===0){ appendLog(msg.slice(4)); }
|
||
else if(msg.indexOf('POS:')===0){ var p=msg.split(':'); if(p.length>=3){ if($('posX'))$('posX').textContent=p[1]; if($('posY'))$('posY').textContent=p[2]; } }
|
||
else if(msg.indexOf('AXISCFG:')===0){
|
||
try{
|
||
var a=JSON.parse(msg.slice(8));
|
||
if(!axisOnline||a.device_id!==activeDevice||![a.x,a.y,a.base,a.x_budget,a.y_budget].every(Number.isInteger)||a.x<1||a.x>8||a.y<1||a.y>8||a.base<1||a.x_budget!==a.x*a.base||a.y_budget!==a.y*a.base||a.x_budget>64||a.y_budget>64) return;
|
||
axisConfirmed=a;
|
||
if(axisRequested&&Object.keys(axisRequested).every(function(key){return axisRequested[key]===a[key];})) axisRequested=null;
|
||
renderAxisSchedule();
|
||
}catch(e){}
|
||
}
|
||
else if(msg.indexOf('FW:')===0){ ota.device = msg.slice(3); renderOta(); }
|
||
else if(msg.indexOf('OTA_STATE:')===0){ try{ var d=JSON.parse(msg.slice(10)); if(d.device_id===activeDevice) ota=d; renderOta(); }catch(e){} }
|
||
}
|
||
function connect(){
|
||
if(!activeDevice) return;
|
||
var ctx=selection();
|
||
var proto = location.protocol==='https:' ? 'wss' : 'ws';
|
||
var socket=new WebSocket(proto+'://'+location.host+'/ws/client?device_id='+encodeURIComponent(ctx.id));
|
||
ws=socket;
|
||
socket.onmessage = function(ev){ if(!isCurrent(ctx)||ws!==socket) return; try{ onMsg(ev.data); }catch(e){ console.error(e); } };
|
||
socket.onclose = function(){ if(!isCurrent(ctx)||ws!==socket) return; setStatus(false);setDeviceReady(false); axisConfirmed=null;axisRequested=null;axisOnline=false;renderAxisSchedule(); if($('lastUpdate')) $('lastUpdate').textContent='reconectando...'; reconnectTimer=setTimeout(function(){if(isCurrent(ctx)) connect();},3000); };
|
||
socket.onerror = function(){ socket.close(); };
|
||
}
|
||
function setWindow(sec, btn){ WIN=sec; var bs=document.querySelectorAll('.cb-group button[data-win]'); bs.forEach(function(b){ b.classList.toggle('btn-active', b===btn); }); renderAll(); }
|
||
function sendRaw(cmd){ if(deviceReady && ws && ws.readyState===1){ ws.send(cmd); appendLog('[hub] Solicitud enviada a '+deviceLabel(activeDevice)+'. La ejecucion requiere confirmacion del dispositivo.'); return true; } return false; }
|
||
function sendCoords(){ var x=$('inPosX').value, y=$('inPosY').value; if(x===''||y===''){ alert('Ingresa X y Y'); return; } sendRaw('SET_COORDS:'+x+':'+y); }
|
||
function sendParam(p, id){
|
||
var v=$(id).value; if(v==='') return;
|
||
if(p==='cicleMax'&&(!/^\d+$/.test(v)||+v<1||+v>64)){alert('La base debe ser un entero entre 1 y 64');return;}
|
||
var factors=axisRequested&&axisRequested.x!=null&&axisRequested.y!=null?axisRequested:axisConfirmed;
|
||
if(p==='cicleMax'&&factors&&(+v*factors.x>64||+v*factors.y>64)){alert('La base por cada factor no puede superar 64 ciclos');return;}
|
||
if(sendRaw('SET_PARAM:'+p+'='+v)&&p==='cicleMax'){
|
||
axisRequested=Object.assign({},axisRequested||{},{base:+v}); renderAxisSchedule();
|
||
}
|
||
}
|
||
function sendAxisFactors(){
|
||
var x=$('axisXFactor').value,y=$('axisYFactor').value;
|
||
if(!/^\d+$/.test(x)||!/^\d+$/.test(y)||+x<1||+x>8||+y<1||+y>8){alert('Los factores deben ser enteros entre 1 y 8');return;}
|
||
if(!axisOnline||!deviceReady||!ws||ws.readyState!==1){setT('axisSchedule','No enviado: la tarjeta o la conexión no están disponibles.');return;}
|
||
var base=axisRequested&&axisRequested.base!=null?axisRequested.base:axisConfirmed&&axisConfirmed.base;
|
||
if(base!=null&&(base*(+x)>64||base*(+y)>64)){alert('La base por cada factor no puede superar 64 ciclos');return;}
|
||
axisRequested=Object.assign({},axisRequested||{},{x:+x,y:+y}); renderAxisSchedule();
|
||
sendRaw('SET_PARAM:axisXFactor='+x); sendRaw('SET_PARAM:axisYFactor='+y);
|
||
}
|
||
function applyCalib(){ var ctx=selection(), f=Number($('calPyrano').value); if(!deviceReady) return; if(!(f>0)||!Number.isFinite(f)){ alert('Factor inválido'); return; } $('calNote').textContent='aplicando...'; deviceRequest('/calib',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mV_per_WM2:f})},ctx).then(function(d){if(isCurrent(ctx)) $('calNote').textContent='mV/W/m² = '+d.mV_per_WM2;}).catch(function(e){actionError(ctx,'calNote',e);}); }
|
||
function applyPcb(){ var ctx=selection(), v=$('pcbSel').value; if(!deviceReady) return; setT('pcbNote','aplicando...');
|
||
deviceRequest('/pcb',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version:v})},ctx)
|
||
.then(function(d){if(isCurrent(ctx)) setT('pcbNote','shunt = '+d.r_shunt+' Ω');})
|
||
.catch(function(e){if(isCurrent(ctx)&&cfg) $('pcbSel').value=cfg.pcb_version; actionError(ctx,'pcbNote',e);});
|
||
}
|
||
function loadLogs(){ var ctx=selection(); if(!ctx.id) return;
|
||
deviceRequest('/logs',{},ctx).then(function(rows){
|
||
if(!isCurrent(ctx)) return; var tb=$('logsTableBody');tb.textContent='';
|
||
if(!rows.length){var tr=document.createElement('tr'),td=document.createElement('td');td.colSpan=3;td.textContent='Sin archivos de log';tr.appendChild(td);tb.appendChild(tr);return;}
|
||
rows.forEach(function(r){var tr=document.createElement('tr');[r.date,r.size_kb+' KB'].forEach(function(text){var td=document.createElement('td');td.textContent=text;tr.appendChild(td);});var td=document.createElement('td'),a=document.createElement('a');a.href=deviceUrl(ctx.id,'/logs/'+encodeURIComponent(r.filename));a.download=r.filename;a.textContent='Descargar';td.appendChild(a);tr.appendChild(td);tb.appendChild(tr);});
|
||
}).catch(function(e){if(isCurrent(ctx)&&e.name!=='AbortError') appendLog('Error al cargar logs: '+e.message);});
|
||
}
|
||
|
||
// OTA
|
||
var ota = { device:null, available:null, auto:false, url:null, connected:false };
|
||
function renderOta(){
|
||
var devEl=$('otaDevice'), avEl=$('otaAvail'), push=$('otaPush'), meta=$('otaMeta'), autoCb=$('otaAuto');
|
||
if(!devEl) return;
|
||
devEl.textContent = ota.device || 'sin informar';
|
||
if(ota.available && ota.available.version){
|
||
var upToDate = ota.device && ota.device === ota.available.version;
|
||
avEl.textContent = ota.available.version + (upToDate ? ' (al día ✓)' : ' → dispositivo: ' + (ota.device||'--'));
|
||
avEl.style.color = upToDate ? '#4ade80' : '#fbbf24';
|
||
if(meta){ var m=ota.available;
|
||
meta.textContent = 'Tamaño: ' + (m.size!=null ? m.size+' B' : '?')
|
||
+ (m.sha256 ? ' · sha256 '+m.sha256.substring(0,12) : '')
|
||
+ (m.uploaded_at ? ' · subido '+m.uploaded_at : ''); }
|
||
} else { avEl.textContent = 'sin imagen subida'; avEl.style.color = '#64748b'; if(meta) meta.textContent=''; }
|
||
if(push) push.disabled = !(deviceReady && ota.connected && ota.available && ota.available.sha256);
|
||
if(autoCb) autoCb.checked = !!ota.auto;
|
||
}
|
||
function loadOta(){
|
||
var ctx=selection();if(!ctx.id) return;
|
||
deviceRequest('/status',{},ctx).then(function(s){
|
||
if(!isCurrent(ctx)) return;
|
||
if(s && s.ota && s.device_id===ctx.id && s.ota.device_id===ctx.id) ota=s.ota;
|
||
renderOta();
|
||
}).catch(function(e){actionError(ctx,'otaStatus',e);});
|
||
}
|
||
async function uploadOta(){
|
||
var ctx=selection();if(!deviceReady||!ctx.id) return;
|
||
var fileEl=$('otaFile'), verEl=$('otaVersion'), st=$('otaStatus');
|
||
if(!fileEl || !fileEl.files || !fileEl.files.length){ if(st) st.textContent='Selecciona un archivo .bin primero.'; return; }
|
||
if(st) st.textContent='Subiendo imagen compartida; no se actualizara ninguna tarjeta...';
|
||
var fd=new FormData(); fd.append('file', fileEl.files[0]);
|
||
if(verEl && verEl.value.trim()) fd.append('version', verEl.value.trim());
|
||
try{
|
||
var response=await fetch('/ota/upload',{method:'POST',body:fd,signal:ctx.signal});
|
||
var result=await response.json();
|
||
if(!isCurrent(ctx)) return;
|
||
if(!response.ok) throw new Error(result.detail||'Error HTTP '+response.status);
|
||
ota.available=result.manifest; if(result.manifest) ota.url=result.manifest.url;
|
||
st.textContent='Imagen subida: '+result.manifest.version+'. Ninguna tarjeta actualizada. Destino seleccionado: '+deviceLabel(ctx.id)+'.';
|
||
fileEl.value='';renderOta();
|
||
}catch(e){actionError(ctx,'otaStatus',e);}
|
||
}
|
||
function pushOta(){
|
||
var ctx=selection();if(!deviceReady||!ctx.id) return;
|
||
var st=$('otaStatus');
|
||
if(!ota.connected){ if(st) st.textContent='La ESP32 no está conectada.'; return; }
|
||
var manifest=ota.available;
|
||
if(!manifest||!manifest.sha256) return;
|
||
if(!confirm('Actualizar SOLO '+deviceLabel(ctx.id)+' a la version '+manifest.version+' (SHA-256 '+manifest.sha256.slice(0,12)+')? La tarjeta descargara la imagen y se reiniciara.')) return;
|
||
if(!isCurrent(ctx)) return;
|
||
st.textContent='Enviando solicitud OTA a '+deviceLabel(ctx.id)+'...';
|
||
deviceRequest('/ota/push',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sha256:manifest.sha256})},ctx)
|
||
.then(function(){
|
||
if(!isCurrent(ctx)) return;
|
||
st.textContent='Solicitud OTA enviada a '+deviceLabel(ctx.id)+'. Pendiente de confirmar la version tras la reconexion; no implica instalacion completada.';
|
||
setTimeout(function(){if(isCurrent(ctx)) loadOta();},4000);
|
||
}).catch(function(e){actionError(ctx,'otaStatus',e);});
|
||
}
|
||
function saveOtaAuto(){
|
||
var ctx=selection();if(!deviceReady||!ctx.id) return;
|
||
var st=$('otaStatus'), val = $('otaAuto') ? $('otaAuto').checked : false;
|
||
if(val&&!confirm('Permitir actualizacion automatica SOLO para '+deviceLabel(ctx.id)+' al conectar? Puede reiniciar esa tarjeta.')){ $('otaAuto').checked=!!ota.auto; return; }
|
||
if(!isCurrent(ctx)) return;
|
||
deviceRequest('/ota/auto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:val})},ctx)
|
||
.then(function(j){
|
||
if(!isCurrent(ctx)) return;
|
||
ota.auto=!!j.enabled; renderOta();
|
||
if(st) st.textContent='Auto-update para '+deviceLabel(ctx.id)+': '+(j.enabled?'ACTIVADO al (re)conectar.':'desactivado.');
|
||
}).catch(function(e){if(isCurrent(ctx)) renderOta(); actionError(ctx,'otaStatus',e);});
|
||
}
|
||
window.addEventListener('load', function(){ buildCharts(); resetDevicePanel(); refreshFleet(); setInterval(refreshFleet,5000); });
|
||
</script>
|
||
</body>
|
||
</html>
|