const STATUS_LABELS = { disconnected: 'Disconnected — select a port and connect', connecting: 'Connecting…', connected_disarmed: 'Connected · Disarmed', armed: 'Connected · Armed', link_degraded: 'Signal lost — check tether', }; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0; const api=(name,...args)=>window.pywebview.api[name](...args); addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)} window.showToast=toast; function populatePorts(ports){ const p=$('#port'); if(!p)return; const old=p.value||''; p.innerHTML=''; const allList=[]; const seen=new Set(); const addPort=item=>{ let dev=item, lbl=item; if(item&&typeof item==='object'){ dev=item.device||''; lbl=item.label||dev; } if(dev&&!seen.has(dev)){ seen.add(dev); allList.push({device:dev, label:lbl}); } }; if(Array.isArray(ports)) ports.forEach(addPort); addPort({device:'demo', label:'Demo Mode (no hardware)'}); let matched=false; allList.forEach(item=>{ const opt=new Option(item.label, item.device); p.add(opt); if(item.device===old){ p.value=item.device; matched=true; } }); if(!matched&&allList.length>0){ p.value=allList[0].device; } } function sendAxes(){if(!bridgeReady||appState!=='armed')return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}} function updateReadouts(){ if(appState!=='armed'){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);} window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();}; function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0; function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} heave.addEventListener('pointerdown',e=>{if(appState!=='armed')return;heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()}); function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave();} addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&engageButton?.classList.contains('arm')&&!e.repeat)startEngageHold();return;}if(e.key==='F1'){e.preventDefault();if(appState==='connected_disarmed'&&!engageButton?.classList.contains('arm')&&!e.repeat)engageButton?.click();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();}); addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()}); function openModal(id){ const el=typeof id==='string'?document.getElementById(id):id; if(!el)return; el.classList.remove('closing'); if(!el.open)el.showModal(); } function closeModal(dialog){ if(!dialog||!dialog.open||dialog.classList.contains('closing'))return; dialog.classList.add('closing'); let done=false; const finish=()=>{ if(done)return; done=true; dialog.removeEventListener('animationend',finish); dialog.classList.remove('closing'); dialog.close(); }; dialog.addEventListener('animationend',finish,{once:true}); setTimeout(finish,300); } $$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open)); $$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog'))); $$('dialog').forEach(d=>{ d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)}); d.addEventListener('click',e=>{ const r=d.getBoundingClientRect(); if(e.clientXr.right||e.clientYr.bottom)closeModal(d); }); }); $('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}}; const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage'); async function handleStlFile(file){ if(!file)return; try{ if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh (*.stl)'); if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh'); modelButton.disabled=true; modelButton.textContent='Loading…'; const buffer=await file.arrayBuffer(); const result=window.rovModel.loadBuffer(buffer,file.name); modelButton.textContent='Replace STL'; toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`); if(bridgeReady&&window.pywebview?.api?.save_model){ const dataUrl=await new Promise((resolve,reject)=>{ const reader=new FileReader(); reader.onload=()=>resolve(reader.result); reader.onerror=()=>reject(reader.error); reader.readAsDataURL(file); }); api('save_model',file.name,String(dataUrl).split(',')[1]).catch(e=>console.warn(e)); } }catch(error){ toast(`STL import failed: ${error.message}`,true); }finally{ modelButton.disabled=false; if(modelButton.textContent==='Loading…')modelButton.textContent='Load STL'; } } modelButton.onclick=async()=>{ let loaded=false; if(window.pywebview?.api?.choose_model){ try{ const model=await api('choose_model'); if(model&&model.data){ const res=window.rovModel.load(model); modelButton.textContent='Replace STL'; toast(`Loaded ${res.name} • ${res.triangles.toLocaleString()} triangles`); loaded=true; } }catch(err){ console.warn('choose_model error:', err); } } if(!loaded&&modelInput){ modelInput.value=''; modelInput.click(); } }; if(modelInput){ modelInput.onchange=()=>{ const file=modelInput.files?.[0]; if(file)handleStlFile(file); }; } if(rovStage){ rovStage.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.add('drag-over')}); rovStage.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over')}); rovStage.addEventListener('drop',e=>{ e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over'); const file=e.dataTransfer?.files?.[0]; if(file)handleStlFile(file); }); } $('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))}; $('#connect').onclick=async()=>{ const btn=$('#connect'); const label=btn.querySelector('.btn-label')||btn; const p=$('#port'); const port=p?p.value:''; if(appState==='connecting'){ label.textContent='Canceling…'; try{ await api('disconnect'); }catch(_){} return; } if(appState!=='disconnected'){ label.textContent='Disconnecting…'; try{ await api('disconnect'); toast('Disconnected'); }catch(e){ toast(e.message,true); } }else{ if(!port){ toast('Select or enter a port to connect',true); return; } btn.classList.add('is-connecting'); label.textContent='Connecting…'; const statusEl=$('#status-text'); if(statusEl)statusEl.textContent=`Opening ${port}…`; toast(`Connecting to ${port}…`); try{ const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value); if(confirmedState)render(confirmedState); toast('MAVLink connected'); }catch(e){ btn.classList.remove('is-connecting'); label.textContent='Connect'; toast(e.message,true); if(statusEl)statusEl.textContent=`Connection error: ${e.message}`; } } }; const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input'); if(customPortBtn&&customPortInput){ const applyCustomPort=()=>{ const val=customPortInput.value.trim(); if(!val)return; const p=$('#port'); let exists=false; for(const opt of p.options){ if(opt.value===val){opt.selected=true;exists=true;break;} } if(!exists){ p.add(new Option(`Custom (${val})`, val), 0); p.value=val; } closeModal($('#link-modal')); toast(`Selected port: ${val}`); }; customPortBtn.onclick=applyCustomPort; customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}}; } const engageButton=$('#engage'); let engageHoldTimer=null, engageStart=0, engageCharged=false, settingMode=false; const ENGAGE_HOLD_MS=1000; function isManualMode(s){ const m=(s&&s.mode)?String(s.mode).toUpperCase():''; return m==='MANUAL'; } function updateEngageProgress(){ if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')){ stopEngageHold(true); return; } const elapsed=performance.now()-engageStart; const progress=Math.min(1,elapsed/ENGAGE_HOLD_MS); engageButton.style.setProperty('--arm-progress',`${progress*100}%`); if(progress>=1){ engageCharged=true; stopEngageHold(false); engageButton.classList.add('charged'); setTimeout(()=>engageButton.classList.remove('charged'),600); api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true)); return; } engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function startEngageHold(e){ if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return; if(engageStart||engageCharged)return; if(e&&e.button!==undefined&&e.button!==0)return; engageStart=performance.now(); engageCharged=false; engageButton.classList.add('charging'); engageButton.style.setProperty('--arm-progress','0%'); engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function stopEngageHold(wasCancelled=true){ if(!engageStart)return; const elapsed=performance.now()-engageStart; cancelAnimationFrame(engageHoldTimer); engageHoldTimer=null; engageStart=0; engageButton.classList.remove('charging'); engageButton.style.setProperty('--arm-progress','0%'); if(wasCancelled&&!engageCharged&&elapsed70){ toast('Hold for 1s to Engage vehicle'); } engageCharged=false; } if(engageButton){ engageButton.addEventListener('pointerdown',startEngageHold); engageButton.addEventListener('pointerup',()=>stopEngageHold(true)); engageButton.addEventListener('pointerleave',()=>stopEngageHold(true)); engageButton.addEventListener('pointercancel',()=>stopEngageHold(true)); engageButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.onclick=async()=>{ if(appState==='armed'){ try{ await api('command','disarm'); toast('Disarm command sent'); }catch(e){ toast(e.message,true); } }else if(appState==='connected_disarmed'&&!engageButton.classList.contains('arm')){ try{ settingMode=true; const lbl=$('#engage-label'); if(lbl)lbl.textContent='Setting mode…'; await api('command','manual'); toast('Manual mode requested'); }catch(e){ settingMode=false; toast(e.message,true); } } }; } $('#logs').onclick=()=>api('open_logs'); function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)} $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); $('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}}; function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}} function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}} function render(s){ appState=s.app_state||'disconnected'; document.body.dataset.appState=appState; const isOnline=(appState==='connected_disarmed'||appState==='armed'); const connectBtn=$('#connect'); const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null; if(connectBtn){ if(appState==='connecting'){ if(connectLabel)connectLabel.textContent='Connecting…'; else connectBtn.textContent='Connecting…'; connectBtn.classList.add('is-connecting'); connectBtn.classList.remove('is-connected'); }else if(appState!=='disconnected'){ if(connectLabel)connectLabel.textContent='Disconnect'; else connectBtn.textContent='Disconnect'; connectBtn.classList.remove('is-connecting'); connectBtn.classList.add('is-connected'); }else{ if(connectLabel)connectLabel.textContent='Connect'; else connectBtn.textContent='Connect'; connectBtn.classList.remove('is-connecting', 'is-connected'); } } const engageBtn=$('#engage'); const engageLabel=$('#engage-label'); if(engageBtn&&engageLabel){ if(appState==='armed'){ settingMode=false; engageBtn.disabled=false; engageBtn.className='button danger'; engageBtn.title='Disarm (F3)'; engageLabel.textContent='Disarm'; }else if(appState==='connected_disarmed'){ const manualConfirmed=isManualMode(s); if(manualConfirmed){ settingMode=false; engageBtn.disabled=false; engageBtn.className='button arm'; engageBtn.title='Hold to Engage (F2)'; engageLabel.textContent='Hold to Engage'; }else{ engageBtn.disabled=false; engageBtn.className='button subtle'; engageBtn.title='Set Manual Mode (F1)'; engageLabel.textContent=settingMode?'Setting mode…':'Engage'; } }else{ settingMode=false; engageBtn.disabled=true; engageBtn.className='button subtle'; engageBtn.title='Engage'; engageLabel.textContent='Engage'; } } const motorBtn=$('[data-open="motors-modal"]'); if(motorBtn)motorBtn.disabled=(!isOnline); const statusEl=$('#status-text'); if(statusEl){ if(s.status){ statusEl.textContent=s.status; }else{ statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected; } } updateReadouts(); const b=s.battery; if(b){ $('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`; $('#battery-fill').style.width=`${b.percent}%`; $('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'; }else{ $('#battery-title').textContent='—'; $('#battery-percent').textContent='waiting'; $('#battery-fill').style.width='0'; } renderMotors(s.servos); } async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)} let modelLoadPromise=null; async function loadDefaultModel(){ if(modelLoadPromise)return modelLoadPromise; modelLoadPromise=(async()=>{ const statusEl=$('#model-status'); // 1. Direct high-speed HTTP fetch (instant, ~20ms) for(const url of [ '/model.stl', '/model', '/runtime/vehicle_model.stl', '/assets/Main%20Assembly%20V1%20(2).stl', '../runtime/vehicle_model.stl', '../assets/Main%20Assembly%20V1%20(2).stl' ]){ try{ const res=await fetch(url); if(res.ok){ if(statusEl)statusEl.textContent='Loading mesh…'; const buf=await res.arrayBuffer(); if(window.rovModel){ const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl'); $('#load-model').textContent='Replace STL'; console.log('[Direct Fetch] Model loaded:', result); return true; } } }catch(err){ console.warn(`[Direct Fetch] ${url} failed:`, err); } } // 2. Python Bridge Chunked Stream (fallback) if(window.pywebview?.api?.get_saved_model_info){ try{ const info=await api('get_saved_model_info'); if(info&&info.size){ const total=info.size, chunk=262144; const bytes=new Uint8Array(total); let loaded=0; if(statusEl){ statusEl.className='model-status'; statusEl.textContent=`Streaming ${info.name} (0%)…`; } while(loadedsetTimeout(r,0)); } if(window.rovModel){ const res=window.rovModel.loadBuffer(bytes.buffer,info.name); $('#load-model').textContent='Replace STL'; return true; } } }catch(e){ console.warn('[Bridge Stream] Failed:', e); if(statusEl){ statusEl.className='model-status error'; statusEl.textContent=`Bridge Error: ${e.message}`; } } } return false; })(); return modelLoadPromise; } let booted=false; async function boot(){ if(booted||!window.pywebview?.api)return; booted=true; bridgeReady=true; try{ const init=await api('initial_state'); batteryConfig=init.battery_config; populatePorts(init.ports); fillBatteryForm(); render(init.state); poll(); // Populate the serial selector before starting the optional model load. // A large cached mesh must never delay an operator seeing USB COM ports. loadDefaultModel(); setInterval(async()=>{ if(appState==='disconnected'){ try{populatePorts(await api('refresh_ports'))}catch(_){} } },2000); }catch(e){ toast(`Python bridge: ${e.message}`,true); } } function waitForBridge(){ if(window.pywebview?.api)boot(); else setTimeout(waitForBridge,50); } addEventListener('pywebviewready',boot); if(document.readyState==='loading'){ addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); }else{ loadDefaultModel(); waitForBridge(); }