8ccd7689f7
Add configurable Xbox motion and action bindings, frame-aware six-axis MAVLink controls, sensor logging, camera viewing and recording, and a faster startup path. Synchronize both app copies and document companion hardware setup and verification limits.
1025 lines
64 KiB
JavaScript
1025 lines
64 KiB
JavaScript
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,lateral:0,pitch:0,roll:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
|
|
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
|
|
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,controllerDraft=null,controllerNeedCenter=true,controllerWasDriving=false;
|
|
let controllerProfiles={};try{controllerProfiles=JSON.parse(localStorage.getItem('sub-v2-profiles'))||{}}catch(_){}
|
|
const controllerMotion=new ControllerInput.Motion();
|
|
let controllerActionState={},controllerActionsReady=false;
|
|
const controllerId=pad=>pad.index+':'+pad.id;
|
|
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
|
|
function loadControllerConfig(pad){
|
|
let saved=controllerProfiles[pad?.id||'$xbox']||controllerProfiles.$xbox;
|
|
if(!saved&&pad)try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
|
|
const config=ControllerInput.migrate(saved,pad);
|
|
return ControllerInput.validProfile(config)?config:ControllerInput.preset();
|
|
}
|
|
function selectController(pad){
|
|
resetInputs();controllerKey=pad?controllerId(pad):null;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'));
|
|
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller output':'Steering and throttle joystick; W A S D keys');
|
|
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output':'Vertical thrust control');
|
|
return true;
|
|
}
|
|
function inputConfigurationLocked(){return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known))}
|
|
function syncInputModeControls(){
|
|
const locked=inputConfigurationLocked();
|
|
$('.input-mode-switch').dataset.locked=String(locked);
|
|
for(const button of [$('#input-keyboard'),$('#input-joystick'),$('#controller-settings')])button.disabled=locked;
|
|
if($('#controller-modal').open)for(const field of $('#controller-form').querySelectorAll('input,select,button[type=submit],button[data-swap]'))field.disabled=locked;
|
|
}
|
|
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
|
|
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
|
|
$('#input-joystick').onclick=()=>setInputMode('joystick');
|
|
const axisLabels={forward:'Forward',lateral:'Strafe',vertical:'Vertical',turn:'Turn',pitch:'Pitch',roll:'Roll'};
|
|
function fillControllerForm(keepDraft=false){
|
|
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
|
|
device.replaceChildren(new Option('Xbox · offline',''),...pads.map(pad=>new Option(pad.id,controllerId(pad))));
|
|
device.value=controllerKey||'';
|
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
|
|
if(!keepDraft)controllerDraft=structuredClone(controllerConfig||loadControllerConfig(pad));
|
|
const config=controllerDraft;
|
|
$('#controller-preset').value=['triggers3','sixaxis','classic'].includes(config.preset)?config.preset:'custom';
|
|
const rows=$('#controller-bindings');rows.replaceChildren();
|
|
for(const name of ControllerInput.names){
|
|
const row=document.createElement('div');row.className='binding-row';row.dataset.axis=name;
|
|
const label=document.createElement('span');label.textContent=axisLabels[name];
|
|
const meter=document.createElement('meter');meter.min=-1;meter.max=1;meter.value=0;meter.setAttribute('aria-label',axisLabels[name]+' demand');label.append(meter);row.append(label);
|
|
for(const direction of ['negative','positive']){
|
|
const select=document.createElement('select');select.name=name+'_'+direction;select.setAttribute('aria-label',axisLabels[name]+' '+direction);
|
|
for(const [value,text] of ControllerInput.sources(pad))select.add(new Option(text,value));
|
|
select.value=config.bindings[name][direction];
|
|
select.onchange=()=>{config.bindings[name][direction]=select.value;config.preset='custom';$('#controller-preset').value='custom'};
|
|
row.append(select);
|
|
}
|
|
const swap=document.createElement('button');swap.type='button';swap.dataset.swap=name;swap.className='binding-swap';swap.textContent='⇄';swap.title='Reverse '+axisLabels[name];swap.setAttribute('aria-label','Reverse '+axisLabels[name]);
|
|
swap.onclick=()=>{const item=config.bindings[name];[item.negative,item.positive]=[item.positive,item.negative];config.preset='custom';fillControllerForm(true)};row.append(swap);
|
|
const scale=document.createElement('input');scale.type='number';scale.min=0;scale.max=100;scale.step=1;scale.value=Math.round(config.bindings[name].scale*100);scale.setAttribute('aria-label',axisLabels[name]+' limit');
|
|
scale.oninput=()=>{config.bindings[name].scale=Number(scale.value)/100;config.preset='custom';$('#controller-preset').value='custom'};row.append(scale);rows.append(row);
|
|
}
|
|
for(const action of ['lights','stream','record']){
|
|
const select=form.elements[action];select.replaceChildren(...ControllerInput.sources(pad).filter(([value])=>!value.startsWith('axis:')).map(([value,text])=>new Option(text,value)));
|
|
select.value=config.actions[action];select.onchange=()=>config.actions[action]=select.value;
|
|
}
|
|
form.elements.deadZone.value=Math.round(config.deadZone*100);form.elements.expo.value=Math.round(config.expo*100);
|
|
form.elements.deadZone.oninput=()=>config.deadZone=Number(form.elements.deadZone.value)/100;
|
|
form.elements.expo.oninput=()=>config.expo=Number(form.elements.expo.value)/100;
|
|
syncInputModeControls();
|
|
}
|
|
$('#controller-device').onchange=e=>{
|
|
if(inputConfigurationLocked())return;
|
|
selectController(getControllers().find(pad=>controllerId(pad)===e.target.value));fillControllerForm();
|
|
};
|
|
$('#controller-preset').onchange=e=>{if(e.target.value==='custom')return;controllerDraft=ControllerInput.preset(e.target.value);fillControllerForm(true)};
|
|
$('#controller-form').onsubmit=async e=>{
|
|
e.preventDefault();const form=e.currentTarget,feedback=form.querySelector('.form-feedback');
|
|
if(inputConfigurationLocked()){setFeedback(feedback,'Disengage first');return}
|
|
if(!ControllerInput.validProfile(controllerDraft)){setFeedback(feedback,'Check inputs');return}
|
|
const pad=getControllers().find(pad=>controllerId(pad)===controllerKey),device=pad?.id||'$xbox';
|
|
try{
|
|
const clean=bridgeReady?await api('save_controller_profile',device,controllerDraft):structuredClone(controllerDraft);
|
|
controllerProfiles[device]=clean;localStorage.setItem('sub-v2-profiles',JSON.stringify(controllerProfiles));controllerConfig=clean;
|
|
resetInputs();closeModal($('#controller-modal'));toast('Mapping saved');
|
|
}catch(error){setFeedback(feedback,error.message||String(error))}
|
|
};
|
|
function pollController(now){
|
|
try{
|
|
if(inputMode==='joystick'||$('#controller-modal').open){
|
|
const pads=getControllers();
|
|
if(controllerKey===null&&pads.length&&!$('#controller-modal').open)selectController(pads[0]);
|
|
const pad=pads.find(pad=>controllerId(pad)===controllerKey),raw=pad&&ControllerInput.read(pad,controllerConfig||loadControllerConfig(pad));
|
|
const target=raw&&(window.V2?.filterDemand(raw)||raw);
|
|
let status=!pad?'No controller':!raw?'Check mapping':controllerNeedCenter&&canDrive()?'Center controls':'Controller ready';
|
|
if(inputMode==='joystick'){
|
|
if(!target||!canDrive()){
|
|
if(controllerWasDriving)resetInputs();controllerNeedCenter=true;controllerWasDriving=false;
|
|
}else{
|
|
if(controllerNeedCenter&&!Object.values(raw).some(value=>value!==0)){controllerNeedCenter=false;status='Controller ready'}
|
|
if(!controllerNeedCenter){
|
|
controllerWasDriving=true;
|
|
Object.assign(control,target);window.joystick3D?.setVisual(target.turn,-target.forward);
|
|
setHeave(target.vertical,false);updateReadouts();sendAxes();
|
|
}
|
|
}
|
|
const active=ControllerInput.actions(pad,controllerConfig);
|
|
if(!Object.values(active).some(Boolean))controllerActionsReady=true;
|
|
if(controllerActionsReady&&inputFocused&&!document.hidden&&!document.querySelector('dialog[open]'))for(const [action,pressed] of Object.entries(active))
|
|
if(pressed&&!controllerActionState[action])window.V2?.action(action);
|
|
controllerActionState=active;
|
|
}
|
|
if($('#controller-modal').open){
|
|
const demand=pad&&ControllerInput.read(pad,controllerDraft);
|
|
for(const meter of $('#controller-bindings').querySelectorAll('meter'))meter.value=demand?.[meter.closest('[data-axis]').dataset.axis]||0;
|
|
$('#controller-axes').textContent=pad?.mapping==='standard'?'Xbox layout':pad?'Raw inputs':'Xbox · offline';
|
|
}
|
|
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
|
|
$('#controller-status').dataset.quiet=String(!!pad&&!!raw);
|
|
}else{$('#controller-status').dataset.quiet='true';$('#controller-status').textContent='Keyboard + touch'}
|
|
}catch(error){if(controllerWasDriving)resetInputs();$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable'}
|
|
requestAnimationFrame(pollController);
|
|
}
|
|
document.body.dataset.inputMode=inputMode;
|
|
requestAnimationFrame(pollController);
|
|
|
|
|
|
function makeDemoState(){const frame=window.V2?.station().frame??4,count=({0:6,1:6,2:8,3:8,4:3,5:4,6:5})[frame],servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?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,current_a:3.2,consumed_mah:320,remaining_mah:5180,consumed_source:'Demo'},capabilities:{frame,firmware:[4,5,3]},accessories:{},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=[{device:'udpin:0.0.0.0:14550',label:'Network · UDP 14550',type:'network'}];
|
|
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,control.lateral,control.pitch,control.roll))
|
|
.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(){ window.V2?.updateDemand(control);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 extra=window.V2?.filterDemand({lateral:(pressedKeys.has('l')?1:0)-(pressedKeys.has('j')?1:0),pitch:(pressedKeys.has('k')?1:0)-(pressedKeys.has('i')?1:0),roll:(pressedKeys.has('o')?1:0)-(pressedKeys.has('u')?1:0)});Object.assign(control,extra);updateReadouts();sendAxes();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('wasdqerfjlikuo'.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');
|
|
for(const axis of Object.keys(control))control[axis]=0;setHeave(0,false);window.V2?.updateDemand(control);controllerActionState={};controllerActionsReady=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.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.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 engageLabelValue=$('#engage-label')?.textContent||'engage';
|
|
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null,engageAppendFrame=null;
|
|
const ENGAGE_HOLD_MS=600;
|
|
|
|
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();
|
|
// Update the state label without delaying the requested control action.
|
|
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 0.6 seconds to engage (F2)':next.message;
|
|
const key=`${next.label}/${next.tone}`;
|
|
if(engageVisualState===key)return;
|
|
const previous=engageVisualState;engageVisualState=key;
|
|
if(engageAppendFrame)cancelAnimationFrame(engageAppendFrame);
|
|
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%');
|
|
const mainLabel=$('#engage-label'),previousLabel=engageLabelValue;
|
|
const appended=previousLabel==='engage'&&label==='engaging'||previousLabel==='disengage'&&label==='disengaging';
|
|
let suffix=null;
|
|
if(appended){
|
|
const previousWidth=mainLabel.getBoundingClientRect().width;
|
|
const stem=previousLabel.slice(0,-1);
|
|
mainLabel.replaceChildren(document.createTextNode(stem));
|
|
suffix=document.createElement('span');suffix.className='engage-suffix';suffix.textContent=label.slice(stem.length);mainLabel.append(suffix);
|
|
const appendedWidth=mainLabel.getBoundingClientRect().width;
|
|
mainLabel.style.setProperty('--engage-append-shift',`${(appendedWidth-previousWidth)/2}px`);
|
|
mainLabel.classList.add('is-appending');
|
|
}else{
|
|
mainLabel.classList.remove('is-appending');
|
|
mainLabel.style.removeProperty('--engage-append-shift');
|
|
mainLabel.textContent=label;
|
|
}
|
|
engageLabelValue=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');
|
|
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
|
|
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
|
|
}
|
|
}
|
|
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.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.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 syncMotorScalingUI(){
|
|
const independent=!!motorSettings.independent;
|
|
document.body.dataset.motorScaling=independent?'independent':'shared';
|
|
const toggle=$('#motor-scale-independent');if(toggle)toggle.checked=independent;
|
|
const gain=$('#gain'),down=$('#gain-down'),up=$('#gain-up');
|
|
if(gain){gain.disabled=independent;gain.title=independent?'Set motor scales in Motor setup':'Shared limit for every motor';}
|
|
if(down)down.disabled=independent;if(up)up.disabled=independent;
|
|
}
|
|
function pushMotorSettings(persist=true){
|
|
if(!bridgeReady)return Promise.resolve(motorSettings);
|
|
const payload=JSON.parse(JSON.stringify(motorSettings));
|
|
motorSettingsQueue=motorSettingsQueue.catch(()=>{}).then(()=>api('save_motor_settings',payload,persist)).then(saved=>{motorSettings=saved;syncMotorScalingUI();if(persist){servoSignature='';renderMotors(vehicleSnapshot.servos||{});toast('Motor settings saved')}return saved}).catch(error=>{setFeedback($('#motor-feedback'),error.message||'Could not save motor settings.');throw error});
|
|
return motorSettingsQueue;
|
|
}
|
|
function motorScale(motor){return motorSettings.independent?(motorSettings.motors[String(motor)]?.scale??100):+$('#gain').value}
|
|
function setGain(value){if(motorSettings.independent)return;const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;servoSignature='';renderMotors(vehicleSnapshot.servos||{});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();
|
|
$('#motor-scale-independent').onchange=e=>{
|
|
if(e.target.checked&&!motorSettings.customized){for(let motor=1;motor<=12;motor++)motorSettings.motors[String(motor)].scale=+$('#gain').value;motorSettings.customized=true}
|
|
motorSettings={...motorSettings,independent:e.target.checked};syncMotorScalingUI();servoSignature='';renderMotors(vehicleSnapshot.servos||{});pushMotorSettings(true).catch(()=>{});
|
|
};
|
|
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 state=servos[String(servo)]||{};
|
|
if((edit.kind==='function'?state.function:state.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.`)}
|
|
}
|
|
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
|
|
const assignments=Object.entries(servos).filter(([,state])=>state.motor).sort((a,b)=>a[1].motor-b[1].motor);
|
|
const servoByMotor=new Map(assignments.map(([servo,state])=>[String(state.motor),{servo,...state}]));
|
|
$('#motor-status').textContent=!vehicleSnapshot.connected?'Offline':vehicleSnapshot.armed?'Armed':`${assignments.length} outputs`;
|
|
const shownMotors=[...new Set(assignments.map(([,state])=>state.motor))].sort((a,b)=>a-b);
|
|
const structure=JSON.stringify([assignments.map(([port,state])=>[port,state.function,state.motor,state.direction]),editable,[...motorEdits],motorSettings.independent,shownMotors,Object.values(motorSettings.motors).map(m=>m.name)]);
|
|
if(structure!==servoSignature){
|
|
servoSignature=structure;const list=$('#motor-list');list.replaceChildren();
|
|
for(const motor of shownMotors){
|
|
const row=document.createElement('article');row.className='motor-row';row.dataset.motor=String(motor);
|
|
row.innerHTML='<input class="motor-name" type="text" maxlength="24" autocomplete="off" aria-label="Motor name"><input class="motor-scale" type="range" min="0" max="100" step="1" aria-label="Motor output scale"><output class="motor-scale-value"></output><button type="button" class="button subtle motor-reverse">Reverse</button><span class="motor-channel"></span>';
|
|
const name=row.querySelector('.motor-name');name.onchange=()=>{motorSettings.motors[String(motor)].name=name.value.trim().slice(0,24)||`Motor ${motor}`;name.value=motorSettings.motors[String(motor)].name;pushMotorSettings(true).catch(()=>{})};
|
|
const scale=row.querySelector('.motor-scale');scale.oninput=()=>{motorSettings.customized=true;motorSettings.motors[String(motor)].scale=+scale.value;row.querySelector('output').textContent=`${scale.value}%`;pushMotorSettings(false).catch(()=>{})};scale.onchange=()=>pushMotorSettings(true).catch(()=>{});
|
|
row.querySelector('.motor-reverse').onclick=()=>{const state=servoByMotor.get(String(motor));if(state?.servo)requestMotorEdit(state.servo,'direction')};list.append(row);
|
|
}
|
|
const details=document.createElement('details');details.className='motor-mapping';const summary=document.createElement('summary');summary.textContent='Assignments';details.append(summary);
|
|
for(let i=1;i<=16;i++){
|
|
const state=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-map-row';row.innerHTML='<span class="motor-map-port"></span><select aria-label="Servo output motor assignment"></select><span class="motor-map-direction"></span>';
|
|
row.querySelector('.motor-map-port').textContent=`SERVO${i}`;const select=row.querySelector('select');select.add(new Option(state.function==null?'Reading…':'Unassigned','0'));for(let motor=1;motor<=12;motor++)select.add(new Option(motorSettings.motors[String(motor)]?.name||`Motor ${motor}`,String(motor)));select.value=String(state.motor||0);select.disabled=!editable||!!pending||state.function==null;select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
|
|
row.querySelector('.motor-map-direction').textContent=pending?'Saving…':state.direction==null?'—':state.direction===-1?'Reversed':'Normal';details.append(row);
|
|
}
|
|
list.append(details);
|
|
}
|
|
for(const motor of shownMotors){
|
|
const row=$(`#motor-list .motor-row[data-motor="${motor}"]`),state=servoByMotor.get(String(motor))||{},settings=motorSettings.motors[String(motor)]||{name:`Motor ${motor}`,scale:20};if(!row)continue;
|
|
const servo=state.servo||null,name=row.querySelector('.motor-name');if(document.activeElement!==name)name.value=settings.name;
|
|
const scale=row.querySelector('.motor-scale');scale.disabled=!motorSettings.independent;if(document.activeElement!==scale)scale.value=motorScale(motor);
|
|
row.querySelector('.motor-scale-value').textContent=`${motorScale(motor)}%`;row.querySelector('.motor-channel').textContent=servo?`SERVO${servo} · ${state.output_us||'—'} µs`:'Unassigned';
|
|
const reverse=row.querySelector('.motor-reverse');reverse.disabled=!editable||!servo||state.direction==null||motorEdits.has(servo);reverse.textContent=state.direction===-1?'Restore':'Reverse';reverse.setAttribute('aria-label',`${reverse.textContent} ${settings.name}`);
|
|
}
|
|
}
|
|
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();
|
|
window.V2?.render(s);
|
|
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;
|
|
const driveEnabled=view.drive;
|
|
document.body.dataset.controlsEnabled=String(driveEnabled);
|
|
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=false;
|
|
|
|
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=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'Voltage unavailable';
|
|
$('#battery-percent').textContent=Number.isFinite(b.percent)?`${Math.round(b.percent)}%`:'—';
|
|
$('#battery-live-voltage').textContent=Number.isFinite(b.voltage)?`${b.voltage.toFixed(2)} V`:'—';
|
|
$('#battery-live-current').textContent=Number.isFinite(b.current_a)?`${b.current_a.toFixed(1)} A`:'Unavailable';
|
|
$('#battery-live-consumed').textContent=Number.isFinite(b.consumed_mah)?`${Math.round(b.consumed_mah).toLocaleString()} mAh`:'Unavailable';
|
|
$('#battery-live-consumed-source').textContent=b.consumed_source||'';
|
|
$('#battery-live-remaining').textContent=Number.isFinite(b.remaining_mah)?`${Math.round(b.remaining_mah).toLocaleString()} mAh`:Number.isFinite(b.remaining_percent)?`${Math.round(b.remaining_percent)}%`:'Unavailable';
|
|
$('#battery-live-note').textContent=s.battery_stale?'Battery telemetry is stale. Check the telemetry rate and battery monitor.':b.charge_source==='Voltage estimate'?'Charge percentage is estimated from voltage. Add a calibrated current sensor for consumption readings.':'Live values come from the flight controller; accuracy depends on its battery monitor setup.';
|
|
$('.battery-card').dataset.stale=String(!view.known||!!s.battery_stale);
|
|
$('.battery-card').title=`${b.charge_source||'Battery status'} · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
|
|
$('#battery-fill').style.width=`${Number.isFinite(b.percent)?b.percent:0}%`;
|
|
$('#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-live-voltage').textContent='—';$('#battery-live-current').textContent='—';
|
|
$('#battery-live-consumed').textContent='—';$('#battery-live-consumed-source').textContent='';
|
|
$('#battery-live-remaining').textContent='—';$('#battery-live-note').textContent='Waiting for battery telemetry.';
|
|
$('.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(loaded<total){
|
|
const reqSize=Math.min(chunk,total-loaded);
|
|
const resp=await api('read_saved_model_chunk',loaded,reqSize);
|
|
const part=atob(resp.data);
|
|
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
|
|
loaded+=part.length;
|
|
const pct=Math.round((loaded/total)*100);
|
|
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
|
|
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(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{
|
|
const init=await withTimeout(api('initial_state'),4000);
|
|
batteryConfig=init.battery_config||{};
|
|
linkConfig=init.link_config||{};
|
|
motorSettings=init.motor_settings||motorSettings;
|
|
controllerProfiles=init.controller_profiles||controllerProfiles;
|
|
controllerConfig=null;
|
|
window.V2?.setStation(init.station_settings||{});
|
|
syncMotorScalingUI();
|
|
fillBatteryForm();
|
|
fillLinkForm();
|
|
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
|
if(!demoActive)render(init.state);
|
|
poll();
|
|
// COM discovery can block on Windows drivers; finish the first paint first.
|
|
setTimeout(()=>withTimeout(api('refresh_ports'),4000).then(populatePorts).catch(()=>{}),0);
|
|
// Let the controls paint before fetching and parsing the large STL mesh.
|
|
const startModelLoad=()=>{void loadDefaultModel()};
|
|
if('requestIdleCallback' in window)requestIdleCallback(startModelLoad,{timeout:2500});
|
|
else setTimeout(startModelLoad,1500);
|
|
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);
|
|
}
|
|
window.V2.init({
|
|
api,bridge:()=>bridgeReady,demo:()=>demoActive,toast,open:openModal,close:closeModal,
|
|
locked:inputConfigurationLocked,reset:resetInputs,touch:()=>window.canTouchControl(),demand:()=>control,
|
|
axis:(name,value)=>{control[name]=value;updateReadouts();sendAxes()},
|
|
scaling:allowed=>{const toggle=$('#motor-scale-independent');toggle.disabled=!allowed||inputConfigurationLocked();toggle.title=allowed?'Independent scales':'SimpleROV 3 only';if(!allowed&&motorSettings.independent){motorSettings.independent=false;syncMotorScalingUI();servoSignature=''}},
|
|
demoAccessory:(name,enabled)=>{if(demoState){demoState.accessories??={};demoState.accessories[name]={requested:enabled,accepted:true,demo:true};render(demoState)}},
|
|
previewFrame:(frame,count)=>{if(demoState){demoState.capabilities={frame,firmware:[4,5,3]};for(let i=1;i<=16;i++)demoState.servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};servoSignature='';render(demoState)}}
|
|
});
|
|
|
|
addEventListener('pywebviewready',boot);
|
|
render(vehicleSnapshot);
|
|
if(document.readyState==='loading'){
|
|
addEventListener('DOMContentLoaded',waitForBridge);
|
|
}else{
|
|
waitForBridge();
|
|
}
|