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}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null; const api=(name,...args)=>window.pywebview.api[name](...args); const activity=[]; let lastReportedStatus=''; function recordActivity(message){ if(!message||activity[0]===message)return; activity.unshift(String(message));activity.length=Math.min(activity.length,3); ['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{ const row=$(selector);if(row){const changed=row.textContent!==(activity[i]||'');row.textContent=activity[i]||'';row.hidden=!activity[i];if(changed&&!row.hidden&&!matchMedia('(prefers-reduced-motion:reduce)').matches)row.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'})} }); } function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})} let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true, bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750; window.canDrive=canDrive; let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false; const controllerMotion=new ControllerInput.Motion(); const controllerId=pad=>pad.index+':'+pad.id; function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}} function loadControllerConfig(pad){ let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){} const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})}; return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults; } function selectController(pad){ resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad); } function setInputMode(mode){ if(mode===inputMode||inputConfigurationLocked())return false; resetInputs();inputMode=mode;document.body.dataset.inputMode=mode; $('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard')); $('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick')); $('#controller-settings').hidden=mode!=='joystick'; $('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys'); $('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control'); return true; } function inputConfigurationLocked(){ return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known)); } function syncInputModeControls(){ const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey); $('.input-mode-switch').dataset.locked=String(locked); for(const button of [$('#input-keyboard'),$('#input-joystick')]){ button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode'; } $('#controller-settings').disabled=locked||!pads.length; $('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup'; if($('#controller-modal').open){ const form=$('#controller-form'); for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad; $('#controller-device').disabled=locked||!pads.length; const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.'; if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback); } } window.canTouchControl=()=>inputMode==='keyboard'&&canDrive(); $('#input-keyboard').onclick=()=>setInputMode('keyboard'); $('#input-joystick').onclick=()=>setInputMode('joystick'); function fillControllerForm(){ const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form'); device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad)))); if(!pads.length)device.add(new Option('No controller detected','')); device.value=controllerKey||''; const pad=pads.find(pad=>controllerId(pad)===controllerKey); const config=controllerConfig||ControllerInput.defaults(pad); for(const name of ['turn','forward','vertical']){ const field=form.elements[name];field.replaceChildren(new Option('Off','-1')); for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis))); field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad; const invert='invert'+name[0].toUpperCase()+name.slice(1); form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad; } form.elements.deadZone.value=Math.round(config.deadZone*100); device.disabled=inputConfigurationLocked()||!pads.length; form.elements.deadZone.disabled=inputConfigurationLocked()||!pad; form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad; } $('#controller-device').onchange=e=>{ if(inputConfigurationLocked())return; const pad=getControllers().find(pad=>controllerId(pad)===e.target.value); if(pad){selectController(pad);fillControllerForm()} }; $('#controller-form').onsubmit=e=>{ e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey); if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return} if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return} const config={deadZone:Number(form.elements.deadZone.value)/100}; for(const name of ['turn','forward','vertical']){ config[name]=Number(form.elements[name].value); const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked; } if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){ setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return; } controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){} resetInputs();closeModal($('#controller-modal')); }; function pollController(now){ try{ if(inputMode==='joystick'){ const pads=getControllers(); if(controllerKey===null&&pads.length)selectController(pads[0]); const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig); let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id; $('#controller-settings').disabled=inputConfigurationLocked()||!pads.length; if(!target||!canDrive()){ if(controllerWasDriving)resetInputs(); controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; }else{ if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id} if(!controllerNeedCenter){ const demand=controllerMotion.step(target,now);controllerWasDriving=true; control.turn=demand.turn;control.forward=demand.forward; window.joystick3D?.setVisual(demand.turn,-demand.forward); setHeave(demand.vertical,false);updateReadouts();sendAxes(); } } if($('#controller-status').textContent!==status)$('#controller-status').textContent=status; $('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready')); if($('#controller-modal').open){ const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected'; if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes; if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm(); } }else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'} }catch(error){ if(controllerWasDriving)resetInputs(); $('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard'; } requestAnimationFrame(pollController); } document.body.dataset.inputMode=inputMode; function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} 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; const manualPorts=new Map(); 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); manualPorts.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(!canDrive()||demoActive||!bridgeReady||axisBusy)return; const transmit=()=>{ axisTimer=0; if(!canDrive()||demoActive||axisBusy)return; lastAxisSend=performance.now();axisBusy=true; withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence)) .catch(()=>markBridgeStale()).finally(()=>axisBusy=false); }; const wait=50-(performance.now()-lastAxisSend); if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait); } // This is a local bridge lease, not extra MAVLink traffic. The worker retains // the configured wire rate and neutralizes if the UI stops renewing its input. setInterval(()=>{if(canDrive())sendAxes()},250); function updateReadouts(){ if(!canDrive()){$('#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(!window.canTouchControl())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 heavePointer=null,heaveAnim=0; function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);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&&canDrive())sendAxes();} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); function easeHeave(target=0){cancelAnimationFrame(heaveAnim);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+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); function resetInputs(){ controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false; if(gainPointer!==null)releaseGain({pointerId:gainPointer}); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); window.joystick3D?.reset(); const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim); if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){} hknob.classList.remove('pressed','released'); control.forward=control.turn=control.vertical=0;setHeave(0,false); if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{}); } function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)} addEventListener('blur',()=>focusChanged(false)); addEventListener('focus',()=>focusChanged(!document.hidden)); document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus())); addEventListener('pagehide',()=>focusChanged(false)); function setFeedback(el,message=''){ if(!el)return; el.textContent=message;el.hidden=!message; } function openModal(id){ const el=typeof id==='string'?document.getElementById(id):id; if(!el||el.open)return; if(el.id==='controller-modal'&&inputConfigurationLocked()){toast('Disengage before changing joystick settings.');return} resetInputs(); el._returnFocus=document.activeElement; if(el.id==='battery-modal')fillBatteryForm(); if(el.id==='controller-modal')fillControllerForm(); if(el.id==='link-modal'){ fillLinkForm(); $('#custom-port-status').textContent=''; $('#custom-port-input').value=''; } el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback)); el.classList.remove('closing'); el.showModal(); } function closeModal(dialog){ if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return; dialog.classList.add('closing'); let done=false; const finish=()=>{ if(done)return; done=true; dialog.removeEventListener('animationend',onEnd); dialog.classList.remove('closing'); dialog.close(); if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true}); }; const onEnd=e=>{if(e.target===dialog)finish()}; dialog.addEventListener('animationend',onEnd); setTimeout(finish,180); } $$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open)); $$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog'))); $('#link-quality').onclick=()=>openModal('link-quality-modal'); let heavyActionBusy=false; async function requestHeavyTest(action){ if(heavyActionBusy)return; heavyActionBusy=true; setFeedback($('#heavy-quality-error')); renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed); try{ const test=await api(action); vehicleSnapshot.heavy_quality=test; renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed); }catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')} finally{ heavyActionBusy=false; renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed); } } $('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test'); $('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test'); $$('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)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.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); try{ if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.'); populatePorts(await withTimeout(api('refresh_ports'),4000)); if(!booted)boot(); }catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)} }; $('#connect').onclick=async()=>{ const btn=$('#connect'); const label=btn.querySelector('.btn-label')||btn; const p=$('#port'); const port=p?p.value:''; if(port==='demo'||demoActive){ resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false; if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')} else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')} return; } if(appState==='connecting'){ label.textContent='Canceling…'; try{ await api('disconnect'); }catch(_){} return; } if(appState!=='disconnected'){ resetInputs(); 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'); recordActivity(`Connecting to ${port}…`); toast(`Connecting to ${port}…`); try{ const confirmedState=await api('connect',port,+linkConfig.baud||57600,+linkConfig.control_hz||20); if(confirmedState)render(confirmedState); toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known); }catch(e){ btn.classList.remove('is-connecting'); label.textContent='Connect'; toast(e.message,true); recordActivity(`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){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;} if(vehicleSnapshot.connected)return; manualPorts.set(val,{device:val,label:`Custom (${val})`}); 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; } $('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`; }; customPortBtn.onclick=applyCustomPort; customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}}; } const engageButton=$('#engage'); const engageProgressLabel=engageButton?.querySelector('.engage-progress-label'); let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null; const ENGAGE_HOLD_MS=1400; function applyEngageVisual(next){ if(!engageButton)return; const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase(); // Interaction updates immediately; the short fade never gates emergency access. engageButton.disabled=!next.action; engageButton.setAttribute('aria-busy',String(next.pending)); engageButton.setAttribute('aria-label',label); engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message; const key=`${next.label}/${next.tone}`; if(engageVisualState===key)return; const previous=engageVisualState;engageVisualState=key; clearTimeout(engageTransitionTimer); engageButton.classList.remove('arm','danger','subtle','charged','pending'); engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm'); engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%'); $('#engage-label').textContent=label; if(engageProgressLabel)engageProgressLabel.textContent=label; const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label; if(previous){ engageButton.classList.remove('state-transition');void engageButton.offsetWidth; engageButton.classList.add('state-transition'); engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240); } } function completeEngage(){ const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%'); requestControl('arm'); } async function requestControl(name){ if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return; const id=++requestSerial; commandError='';localRequest={name,deadline:performance.now()+6000,sent:false}; recordActivity(name==='arm'?'Engage requested':'Disengage requested'); resetInputs();render(vehicleSnapshot); try{ if(demoActive){ // Separate simulated request and simulated heartbeat, just like the real UI. clearTimeout(demoCommandTimer); updateDemo({pending_command:name,command_error:''}); demoCommandTimer=setTimeout(()=>{ if(!demoActive||id!==requestSerial)return; localRequest=null; updateDemo({pending_command:null,armed:name==='arm',armed_known:true, app_state:name==='arm'?'armed':'connected_disarmed', vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`}); },650); }else{ await api('command',name,++inputSequence,Date.now()+2000); if(id===requestSerial&&localRequest)localRequest.sent=true; // Only poll snapshots may confirm state; a send result is not confirmation. } }catch(error){ if(id!==requestSerial)return; localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`; render(vehicleSnapshot);toast(commandError,true); } } function updateEngageProgress(now){ if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false); const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function startEngageHold(e){ if(view.action!=='arm'||!inputFocused||engageStart)return; if(e&&e.button!==undefined&&e.button!==0)return; engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%'); syncInputModeControls(); if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId); engageHoldTimer=requestAnimationFrame(updateEngageProgress); } function cancelEngageHold(showHint=true){ if(!engageStart)return; const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled'); syncInputModeControls(); } if(engageButton){ engageButton.addEventListener('pointerdown',startEngageHold); engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)}); engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)}); engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)}); engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null}); engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)cancelEngageHold(false)}); engageButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.onclick=()=>{ if(suppressEngageClick){suppressEngageClick=false;return} if(view.action==='disarm')requestControl('disarm'); }; } $('#logs').onclick=()=>api('open_logs'); const consoleDock=$('#console-dock'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output'); let consoleRefreshBusy=false,consolePaused=false,consoleCache={rx:[],tx:[]},consoleSignature='',consoleOpen=false,consoleCloseTimer=0; const consolePulseTimers={}; function pulseConsole(side){ const dot=$('.'+side+'-dot'); clearTimeout(consolePulseTimers[side]);dot.classList.add('lit'); consolePulseTimers[side]=setTimeout(()=>dot.classList.remove('lit'),260); } function renderConsole(){ const filter=$('#console-filter').value,signature=filter+JSON.stringify(consoleCache); if(signature===consoleSignature)return; consoleSignature=signature;ConsoleView.render(consoleRx,consoleCache.rx||[],filter);ConsoleView.render(consoleTx,consoleCache.tx||[],filter); } function setConsoleOpen(open){ consoleOpen=open;clearTimeout(consoleCloseTimer);consoleDock.classList.toggle('closing',!open); if(open){consoleDock.hidden=false;$('.app-shell').classList.add('console-open')} else consoleCloseTimer=setTimeout(()=>{consoleDock.hidden=true;$('.app-shell').classList.remove('console-open')},180); $('#console').setAttribute('aria-expanded',String(open)); if(open)refreshConsole();else $('#console').focus({preventScroll:true}); } $('#console').setAttribute('aria-controls','console-dock'); $('#console').setAttribute('aria-expanded','false'); $('#console').onclick=()=>setConsoleOpen(!consoleOpen); $('#console-close').onclick=()=>setConsoleOpen(false); $('#console-filter').onchange=renderConsole; $('#console-pause').onclick=()=>{ consolePaused=!consolePaused; $('#console-pause').textContent=consolePaused?'Resume':'Pause'; $('#console-pause').setAttribute('aria-pressed',String(consolePaused)); $('#console-state').textContent=consolePaused?'Paused':''; $('#console-state').hidden=!consolePaused; if(!consolePaused)refreshConsole(); }; async function refreshConsole(){ if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return; consoleRefreshBusy=true; try{ const sides=await withTimeout(api('read_console',150),1500); if(consolePaused||!consoleOpen)return; const filter=$('#console-filter').value; for(const side of ['rx','tx']){ const previous=new Set(consoleCache[side]||[]); if((sides[side]||[]).some(line=>!previous.has(line)&&(filter==='all'||!ConsoleView.parse(line).quiet)))pulseConsole(side); } consoleCache=sides;renderConsole(); $('#console-state').textContent='';$('#console-state').hidden=true; }catch(error){if(consoleOpen){$('#console-state').hidden=false;$('#console-state').textContent='Unavailable: '+error.message}} finally{consoleRefreshBusy=false} } setInterval(refreshConsole,750); 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); // Explicit pointer ownership makes the full range rail a reliable touch target // in the embedded window, without depending on native range drag defaults. const gainSlider=$('#gain');let gainPointer=null; function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))} function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}} gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()}); gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)}); ['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain)); gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}}); async function saveSettings(form,method,commit,message){ if(form.dataset.busy==='true'||!form.reportValidity())return; const data=Object.fromEntries(new FormData(form)); const button=form.querySelector('[type="submit"]'),label=button.textContent; const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled])); form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…'; setFeedback(form.querySelector('.form-feedback')); let saved=false; try{commit(await api(method,data));saved=true;toast(message)} catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')} finally{ delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label; if(saved)closeModal(form.closest('dialog')); } } $('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')}; function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}} $('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')}; function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}} const motorEdits=new Map(); async function requestMotorEdit(servo,kind,value){ if(motorEdits.has(servo))return; const s=vehicleSnapshot.servos?.[String(servo)]||{}; const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction; setFeedback($('#motor-feedback')); if(demoActive){ if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null}); else s.direction=expected; servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return; } motorEdits.set(servo,{kind,expected,started:performance.now()}); servoSignature='';renderMotors(vehicleSnapshot.servos||{}); try{ await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[])); }catch(error){ motorEdits.delete(servo); setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.'); servoSignature='';renderMotors(vehicleSnapshot.servos||{}); } } function renderMotors(servos){ for(const [servo,edit] of motorEdits){ const s=servos[String(servo)]||{}; if((edit.kind==='function'?s.function:s.direction)===edit.expected){ motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`); }else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){ motorEdits.delete(servo); setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`); } } const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known); const assigned=Object.values(servos).filter(s=>s.motor).length; $('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…'; const signature=JSON.stringify([servos,editable,[...motorEdits]]); if(signature===servoSignature)return; servoSignature=signature; const list=$('#motor-list'),expanded=list.querySelector('details')?.open; list.replaceChildren(); const other=document.createElement('details');other.className='motor-other';other.open=!!expanded; const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary); for(let i=1;i<=16;i++){ const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row'; row.innerHTML=`SERVO${i}`; const select=row.querySelector('select'); select.add(new Option(s.function==null?'Reading…':'Unassigned','0')); for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor))); select.value=String(s.motor||0); select.disabled=!editable||!!pending||s.function==null; select.onchange=e=>requestMotorEdit(i,'function',+e.target.value); row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal'; const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null; reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`); reverse.onclick=()=>requestMotorEdit(i,'direction'); (s.motor?list:other).append(row); } if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)} if(assigned<16)list.append(other); } function renderHeavyQuality(test,connected,armed){ const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel'); if(!status||!results||!run||!stop)return; const quality=demoActive?null:vehicleSnapshot.link_quality; $('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`; $('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`; $('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`; const running=!!test?.running; status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.'); run.disabled=heavyActionBusy||running||!connected||armed||demoActive; run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test'; stop.hidden=!running; stop.disabled=heavyActionBusy; results.replaceChildren(); for(const row of test?.results||[]){ const line=document.createElement('div');line.className='heavy-result-row'; const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`; const stats=document.createElement('span'); const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`; const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`; stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`; line.append(rate,stats);results.append(line); } } function render(s,confirmed=false){ vehicleSnapshot=s; if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error || (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null; const previous=view; view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); syncInputModeControls(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; renderHeavyQuality(s.heavy_quality,s.connected,s.armed); document.body.dataset.appState=appState; document.body.dataset.controlsEnabled=String(view.drive); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale); $('#port').disabled=!!s.connected; $('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true'; $('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true'; 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){ applyEngageVisual(view); } const controlStatus=$('#control-status'); controlStatus.textContent=view.message; controlStatus.classList.toggle('is-error',view.error); controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused)); const motorBtn=$('[data-open="motors-modal"]'); if(motorBtn)motorBtn.disabled=(!isOnline); const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error; const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status); if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)} if(confirmedCommand){ recordActivity(s.armed?'Engage confirmed':'Disengage confirmed'); } updateReadouts(); const b=s.battery; if(b){ $('#battery-title').textContent=`${b.voltage.toFixed(2)} V`; $('#battery-percent').textContent=`${Math.round(b.percent)}%`; $('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale); $('.battery-card').title=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`; $('#battery-fill').style.width=`${b.percent}%`; $('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)'; $('.battery-card').dataset.low=String(!!b.low); }else{ $('#battery-title').textContent='No voltage'; $('#battery-percent').textContent='—'; $('.battery-card').dataset.stale='false'; $('.battery-card').title='Waiting for battery voltage · Battery settings'; $('#battery-fill').style.width='0'; $('.battery-card').dataset.low='false'; } const quality=s.link_quality,qualityCard=$('#link-quality'); if(qualityCard){ if(demoActive){ qualityCard.dataset.grade='offline'; $('#link-quality-grade').textContent='Demo'; $('#link-quality-roundtrip').textContent='no hardware link test'; $('#link-quality-downlink').textContent=''; renderMotors(s.servos||{}); return; } const grade=quality?.grade||(s.connected?'testing':'offline'); qualityCard.dataset.grade=grade; $('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1); if(quality&&quality.probe_samples){ const percent=value=>value==null?'—':`${value.toFixed(1)}%`; const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`; const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`; $('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`; const downLoss=percent(quality.rx_sequence_loss_percent); const badFrames=quality.rx_corrupt_frames_20s??0; const badBytes=quality.rx_corrupt_bytes_20s??0; const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`; $('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`; if(quality.probe_samples<10||quality.rx_packets_30s<10){ $('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`; } }else{ $('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection'; $('#link-quality-downlink').textContent=''; } } renderMotors(s.servos||{}); } function markBridgeStale(){ if(demoActive||bridgeStale)return; bridgeStale=true;resetInputs();render(vehicleSnapshot); } setInterval(()=>{ const now=performance.now(); if(now-lastSafetyTick>750)resetInputs(); lastSafetyTick=now; if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale(); if(localRequest&&performance.now()>localRequest.deadline){ commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`; localRequest=null;render(vehicleSnapshot); } },100); async function poll(){ if(!bridgeReady)return; if(!demoActive)try{ const snapshot=await withTimeout(api('poll')); if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)} }catch(e){markBridgeStale()} 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,booting=false,bootRetry=0,portRefreshTimer=0; function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')} async function boot(){ if(booted||booting||!hasDesktopApi())return; booting=true;clearTimeout(bootRetry); try{ // OS port discovery must not depend on model/config initialization succeeding. populatePorts(await withTimeout(api('refresh_ports'),4000)); const init=await withTimeout(api('initial_state'),4000); batteryConfig=init.battery_config||{}; linkConfig=init.link_config||{}; populatePorts(init.ports); fillBatteryForm(); fillLinkForm(); bridgeReady=true;booted=true;lastBridgeAt=performance.now(); if(!demoActive)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(); if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{ if(appState==='disconnected'||demoActive){ try{populatePorts(await withTimeout(api('refresh_ports'),1500))} catch(error){recordActivity(`Port scan failed: ${error.message}`)} } },2000); }catch(e){ booted=false;bridgeReady=false; recordActivity(`Desktop startup failed; retrying: ${e.message}`); toast(`Python bridge: ${e.message}`,true); bootRetry=setTimeout(boot,1000); }finally{ booting=false; } } function waitForBridge(){ if(booted)return; if(hasDesktopApi())boot(); setTimeout(waitForBridge,250); } addEventListener('pywebviewready',boot); render(vehicleSnapshot); if(document.readyState==='loading'){ addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); }else{ loadDefaultModel(); waitForBridge(); } requestAnimationFrame(pollController);