Files

478 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>