Add reliable built-in demo mode
This commit is contained in:
+267
-51
@@ -1,25 +1,68 @@
|
||||
const STATUS_LABELS = {
|
||||
disconnected: 'Disconnected — select a port and connect',
|
||||
connecting: 'Connecting…',
|
||||
connected_disarmed: 'Connected · Disarmed',
|
||||
armed: 'Connected · Armed',
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||
const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0;
|
||||
const api=(name,...args)=>window.pywebview.api[name](...args);
|
||||
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)});
|
||||
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
|
||||
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
|
||||
window.showToast=toast;
|
||||
function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old}
|
||||
function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||
function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)}
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
const old=p.value||'';
|
||||
p.innerHTML='';
|
||||
|
||||
const allList=[];
|
||||
const seen=new Set();
|
||||
|
||||
window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()};
|
||||
const addPort=item=>{
|
||||
let dev=item, lbl=item;
|
||||
if(item&&typeof item==='object'){
|
||||
dev=item.device||'';
|
||||
lbl=item.label||dev;
|
||||
}
|
||||
if(dev&&!seen.has(dev)){
|
||||
seen.add(dev);
|
||||
allList.push({device:dev, label:lbl});
|
||||
}
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
let matched=false;
|
||||
allList.forEach(item=>{
|
||||
const opt=new Option(item.label, item.device);
|
||||
p.add(opt);
|
||||
if(item.device===old){
|
||||
p.value=item.device;
|
||||
matched=true;
|
||||
}
|
||||
});
|
||||
if(!matched&&allList.length>0){
|
||||
p.value=allList[0].device;
|
||||
}
|
||||
}
|
||||
function sendAxes(){if(!bridgeReady||appState!=='armed')return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}}
|
||||
function updateReadouts(){ if(appState!=='armed'){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
|
||||
|
||||
window.setJoyControl=(x,y)=>{if(appState!=='armed')return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
|
||||
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
|
||||
|
||||
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0;
|
||||
function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes()}
|
||||
function setHeave(v,send=true){if(appState!=='armed'&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send&&appState==='armed')sendAxes();}
|
||||
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
|
||||
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
|
||||
heave.addEventListener('pointerdown',e=>{if(appState!=='armed')return;heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});
|
||||
function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
|
||||
|
||||
function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()}
|
||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()});
|
||||
function applyKeys(){if(appState!=='armed'){keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave();return;}const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave();}
|
||||
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(appState==='connected_disarmed'&&engageButton?.classList.contains('arm')&&!e.repeat)startEngageHold();return;}if(e.key==='F1'){e.preventDefault();if(appState==='connected_disarmed'&&!engageButton?.classList.contains('arm')&&!e.repeat)engageButton?.click();return;}if(e.key==='F3'){e.preventDefault();if(appState==='armed'&&!e.repeat)engageButton?.click();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)){if(appState!=='armed')return;if(!keys.has(k)){keys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopEngageHold(true);return;}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys();});
|
||||
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()});
|
||||
|
||||
function openModal(id){
|
||||
@@ -119,66 +162,237 @@ if(rovStage){
|
||||
if(file)handleStlFile(file);
|
||||
});
|
||||
}
|
||||
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}};
|
||||
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}};
|
||||
const armButton=$('#arm');
|
||||
let armHoldTimer=null, armStart=0, armCharged=false;
|
||||
const ARM_HOLD_MS=1000;
|
||||
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)populatePorts(await api('refresh_ports'))};
|
||||
$('#connect').onclick=async()=>{
|
||||
const btn=$('#connect');
|
||||
const label=btn.querySelector('.btn-label')||btn;
|
||||
const p=$('#port');
|
||||
const port=p?p.value:'';
|
||||
|
||||
function updateArmProgress(){
|
||||
if(!armStart)return;
|
||||
const elapsed=performance.now()-armStart;
|
||||
const progress=Math.min(1,elapsed/ARM_HOLD_MS);
|
||||
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
|
||||
if(appState==='connecting'){
|
||||
label.textContent='Canceling…';
|
||||
try{ await api('disconnect'); }catch(_){}
|
||||
return;
|
||||
}
|
||||
|
||||
if(appState!=='disconnected'){
|
||||
label.textContent='Disconnecting…';
|
||||
try{
|
||||
await api('disconnect');
|
||||
toast('Disconnected');
|
||||
}catch(e){
|
||||
toast(e.message,true);
|
||||
}
|
||||
}else{
|
||||
if(!port){
|
||||
toast('Select or enter a port to connect',true);
|
||||
return;
|
||||
}
|
||||
btn.classList.add('is-connecting');
|
||||
label.textContent='Connecting…';
|
||||
const statusEl=$('#status-text');
|
||||
if(statusEl)statusEl.textContent=`Opening ${port}…`;
|
||||
toast(`Connecting to ${port}…`);
|
||||
try{
|
||||
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
|
||||
if(confirmedState)render(confirmedState);
|
||||
toast('MAVLink connected');
|
||||
}catch(e){
|
||||
btn.classList.remove('is-connecting');
|
||||
label.textContent='Connect';
|
||||
toast(e.message,true);
|
||||
if(statusEl)statusEl.textContent=`Connection error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
const val=customPortInput.value.trim();
|
||||
if(!val)return;
|
||||
const p=$('#port');
|
||||
let exists=false;
|
||||
for(const opt of p.options){
|
||||
if(opt.value===val){opt.selected=true;exists=true;break;}
|
||||
}
|
||||
if(!exists){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
}
|
||||
const engageButton=$('#engage');
|
||||
let engageHoldTimer=null, engageStart=0, engageCharged=false, settingMode=false;
|
||||
const ENGAGE_HOLD_MS=1000;
|
||||
|
||||
function isManualMode(s){
|
||||
const m=(s&&s.mode)?String(s.mode).toUpperCase():'';
|
||||
return m==='MANUAL';
|
||||
}
|
||||
|
||||
function updateEngageProgress(){
|
||||
if(!engageStart||appState!=='connected_disarmed'||!engageButton?.classList.contains('arm')){
|
||||
stopEngageHold(true);
|
||||
return;
|
||||
}
|
||||
const elapsed=performance.now()-engageStart;
|
||||
const progress=Math.min(1,elapsed/ENGAGE_HOLD_MS);
|
||||
engageButton.style.setProperty('--arm-progress',`${progress*100}%`);
|
||||
if(progress>=1){
|
||||
armCharged=true;
|
||||
stopArmHold(false);
|
||||
armButton.classList.add('charged');
|
||||
setTimeout(()=>armButton.classList.remove('charged'),600);
|
||||
engageCharged=true;
|
||||
stopEngageHold(false);
|
||||
engageButton.classList.add('charged');
|
||||
setTimeout(()=>engageButton.classList.remove('charged'),600);
|
||||
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
|
||||
return;
|
||||
}
|
||||
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
|
||||
function startArmHold(e){
|
||||
if(armStart||armCharged)return;
|
||||
function startEngageHold(e){
|
||||
if(appState!=='connected_disarmed'||!engageButton?.classList.contains('arm'))return;
|
||||
if(engageStart||engageCharged)return;
|
||||
if(e&&e.button!==undefined&&e.button!==0)return;
|
||||
armStart=performance.now();
|
||||
armCharged=false;
|
||||
armButton.classList.add('charging');
|
||||
armButton.style.setProperty('--arm-progress','0%');
|
||||
armHoldTimer=requestAnimationFrame(updateArmProgress);
|
||||
engageStart=performance.now();
|
||||
engageCharged=false;
|
||||
engageButton.classList.add('charging');
|
||||
engageButton.style.setProperty('--arm-progress','0%');
|
||||
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
|
||||
}
|
||||
|
||||
function stopArmHold(wasCancelled=true){
|
||||
if(!armStart)return;
|
||||
const elapsed=performance.now()-armStart;
|
||||
cancelAnimationFrame(armHoldTimer);
|
||||
armHoldTimer=null;
|
||||
armStart=0;
|
||||
armButton.classList.remove('charging');
|
||||
armButton.style.setProperty('--arm-progress','0%');
|
||||
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
|
||||
toast('Hold for 1s to Arm vehicle');
|
||||
function stopEngageHold(wasCancelled=true){
|
||||
if(!engageStart)return;
|
||||
const elapsed=performance.now()-engageStart;
|
||||
cancelAnimationFrame(engageHoldTimer);
|
||||
engageHoldTimer=null;
|
||||
engageStart=0;
|
||||
engageButton.classList.remove('charging');
|
||||
engageButton.style.setProperty('--arm-progress','0%');
|
||||
if(wasCancelled&&!engageCharged&&elapsed<ENGAGE_HOLD_MS&&elapsed>70){
|
||||
toast('Hold for 1s to Engage vehicle');
|
||||
}
|
||||
armCharged=false;
|
||||
engageCharged=false;
|
||||
}
|
||||
|
||||
armButton.addEventListener('pointerdown',startArmHold);
|
||||
armButton.addEventListener('pointerup',()=>stopArmHold(true));
|
||||
armButton.addEventListener('pointerleave',()=>stopArmHold(true));
|
||||
armButton.addEventListener('pointercancel',()=>stopArmHold(true));
|
||||
armButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
if(engageButton){
|
||||
engageButton.addEventListener('pointerdown',startEngageHold);
|
||||
engageButton.addEventListener('pointerup',()=>stopEngageHold(true));
|
||||
engageButton.addEventListener('pointerleave',()=>stopEngageHold(true));
|
||||
engageButton.addEventListener('pointercancel',()=>stopEngageHold(true));
|
||||
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
|
||||
engageButton.onclick=async()=>{
|
||||
if(appState==='armed'){
|
||||
try{
|
||||
await api('command','disarm');
|
||||
toast('Disarm command sent');
|
||||
}catch(e){
|
||||
toast(e.message,true);
|
||||
}
|
||||
}else if(appState==='connected_disarmed'&&!engageButton.classList.contains('arm')){
|
||||
try{
|
||||
settingMode=true;
|
||||
const lbl=$('#engage-label');
|
||||
if(lbl)lbl.textContent='Setting mode…';
|
||||
await api('command','manual');
|
||||
toast('Manual mode requested');
|
||||
}catch(e){
|
||||
settingMode=false;
|
||||
toast(e.message,true);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
|
||||
$('#logs').onclick=()=>api('open_logs');
|
||||
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
|
||||
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
|
||||
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}};
|
||||
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
|
||||
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}}
|
||||
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)}
|
||||
function render(s){
|
||||
appState=s.app_state||'disconnected';
|
||||
document.body.dataset.appState=appState;
|
||||
const isOnline=(appState==='connected_disarmed'||appState==='armed');
|
||||
const connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
if(connectBtn){
|
||||
if(appState==='connecting'){
|
||||
if(connectLabel)connectLabel.textContent='Connecting…';
|
||||
else connectBtn.textContent='Connecting…';
|
||||
connectBtn.classList.add('is-connecting');
|
||||
connectBtn.classList.remove('is-connected');
|
||||
}else if(appState!=='disconnected'){
|
||||
if(connectLabel)connectLabel.textContent='Disconnect';
|
||||
else connectBtn.textContent='Disconnect';
|
||||
connectBtn.classList.remove('is-connecting');
|
||||
connectBtn.classList.add('is-connected');
|
||||
}else{
|
||||
if(connectLabel)connectLabel.textContent='Connect';
|
||||
else connectBtn.textContent='Connect';
|
||||
connectBtn.classList.remove('is-connecting', 'is-connected');
|
||||
}
|
||||
}
|
||||
const engageBtn=$('#engage');
|
||||
const engageLabel=$('#engage-label');
|
||||
if(engageBtn&&engageLabel){
|
||||
if(appState==='armed'){
|
||||
settingMode=false;
|
||||
engageBtn.disabled=false;
|
||||
engageBtn.className='button danger';
|
||||
engageBtn.title='Disarm (F3)';
|
||||
engageLabel.textContent='Disarm';
|
||||
}else if(appState==='connected_disarmed'){
|
||||
const manualConfirmed=isManualMode(s);
|
||||
if(manualConfirmed){
|
||||
settingMode=false;
|
||||
engageBtn.disabled=false;
|
||||
engageBtn.className='button arm';
|
||||
engageBtn.title='Hold to Engage (F2)';
|
||||
engageLabel.textContent='Hold to Engage';
|
||||
}else{
|
||||
engageBtn.disabled=false;
|
||||
engageBtn.className='button subtle';
|
||||
engageBtn.title='Set Manual Mode (F1)';
|
||||
engageLabel.textContent=settingMode?'Setting mode…':'Engage';
|
||||
}
|
||||
}else{
|
||||
settingMode=false;
|
||||
engageBtn.disabled=true;
|
||||
engageBtn.className='button subtle';
|
||||
engageBtn.title='Engage';
|
||||
engageLabel.textContent='Engage';
|
||||
}
|
||||
}
|
||||
const motorBtn=$('[data-open="motors-modal"]');
|
||||
if(motorBtn)motorBtn.disabled=(!isOnline);
|
||||
|
||||
const statusEl=$('#status-text');
|
||||
if(statusEl){
|
||||
if(s.status){
|
||||
statusEl.textContent=s.status;
|
||||
}else{
|
||||
statusEl.textContent=STATUS_LABELS[appState]||STATUS_LABELS.disconnected;
|
||||
}
|
||||
}
|
||||
updateReadouts();
|
||||
const b=s.battery;
|
||||
if(b){
|
||||
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
|
||||
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s`;
|
||||
$('#battery-fill').style.width=`${b.percent}%`;
|
||||
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
|
||||
}else{
|
||||
$('#battery-title').textContent='—';
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#battery-fill').style.width='0';
|
||||
}
|
||||
renderMotors(s.servos);
|
||||
}
|
||||
|
||||
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)}
|
||||
let modelLoadPromise=null;
|
||||
@@ -259,7 +473,6 @@ async function boot(){
|
||||
if(booted||!window.pywebview?.api)return;
|
||||
booted=true;
|
||||
bridgeReady=true;
|
||||
loadDefaultModel();
|
||||
try{
|
||||
const init=await api('initial_state');
|
||||
batteryConfig=init.battery_config;
|
||||
@@ -267,8 +480,11 @@ async function boot(){
|
||||
fillBatteryForm();
|
||||
render(init.state);
|
||||
poll();
|
||||
// Populate the serial selector before starting the optional model load.
|
||||
// A large cached mesh must never delay an operator seeing USB COM ports.
|
||||
loadDefaultModel();
|
||||
setInterval(async()=>{
|
||||
if(!connected){
|
||||
if(appState==='disconnected'){
|
||||
try{populatePorts(await api('refresh_ports'))}catch(_){}
|
||||
}
|
||||
},2000);
|
||||
|
||||
@@ -22,23 +22,24 @@
|
||||
<input id="model-input" type="file" accept=".stl,.STL" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div>
|
||||
<div class="vehicle-name"><small>vehicle</small><strong>SimpleROV-3</strong><span>3 thrusters • differential drive</span></div>
|
||||
<div class="rail-bottom">
|
||||
<button class="battery-card ghost" data-open="battery-modal">
|
||||
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</button>
|
||||
<div class="link-state"><span id="link-dot"></span><strong id="link">disconnected</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="connection">
|
||||
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select>
|
||||
<select id="port" aria-label="Serial port">
|
||||
<option value="demo" selected>Demo Mode (no hardware)</option>
|
||||
</select>
|
||||
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button>
|
||||
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button>
|
||||
<button id="connect" class="button connect-button">Connect</button>
|
||||
<button id="connect" class="button connect-button" aria-label="Toggle connection"><span class="btn-spinner" aria-hidden="true"></span><span class="btn-label">Connect</span></button>
|
||||
</div>
|
||||
<div class="topbar-spacer pywebview-drag-region"></div>
|
||||
<div class="window-actions">
|
||||
@@ -71,21 +72,19 @@
|
||||
<section class="bottom-actions">
|
||||
<section class="command-bar">
|
||||
<div class="commands">
|
||||
<button id="manual" class="button subtle"><span>Manual</span></button>
|
||||
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
|
||||
<button id="disarm" class="button danger"><span>Disarm</span></button>
|
||||
<button id="engage" class="button subtle" aria-label="Engage" title="Engage"><span class="arm-copy"><span id="engage-label" class="arm-label">Engage</span><span class="arm-holding-label">Engaging…</span></span></button>
|
||||
</div>
|
||||
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label>
|
||||
<button class="button subtle" data-open="motors-modal">Motor Setup</button>
|
||||
</section>
|
||||
</section>
|
||||
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div>
|
||||
<div class="status-line"><span id="status-dot" class="status-dot"></span><p id="status-text">Disconnected — select a port and connect</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
|
||||
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
|
||||
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user