Improve controller input, console, and UI consistency

This commit is contained in:
2026-10-02 08:02:41 +02:00
parent 0093f3f4aa
commit 31f720a316
18 changed files with 1427 additions and 175 deletions
+178 -25
View File
@@ -14,7 +14,7 @@ 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){row.textContent=activity[i]||'';row.hidden=!activity[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)})})}
@@ -22,7 +22,127 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerNeedCenter=true,controllerWasDriving=false;
const controllerMotion=new ControllerInput.Motion();
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=null;try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const defaults=ControllerInput.defaults(pad),config={...defaults,...(saved||{})};
return Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&ControllerInput.read(pad,config)?config:defaults;
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#controller-settings').hidden=mode!=='joystick';
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller steering and throttle output; read only':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output; read only':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function syncInputModeControls(){
const locked=inputConfigurationLocked(),pads=getControllers(),pad=pads.find(pad=>controllerId(pad)===controllerKey);
$('.input-mode-switch').dataset.locked=String(locked);
for(const button of [$('#input-keyboard'),$('#input-joystick')]){
button.disabled=locked;button.title=locked?'Disengage before changing input mode':'Choose input mode';
}
$('#controller-settings').disabled=locked||!pads.length;
$('#controller-settings').title=locked?'Disengage before changing joystick settings':'Joystick setup';
if($('#controller-modal').open){
const form=$('#controller-form');
for(const field of form.querySelectorAll('input,select,button[type="submit"]'))field.disabled=locked||!pad;
$('#controller-device').disabled=locked||!pads.length;
const feedback=form.querySelector('.form-feedback'),message='Disengage before changing joystick settings.';
if(locked)setFeedback(feedback,message);else if(feedback.textContent===message)setFeedback(feedback);
}
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
const config=controllerConfig||ControllerInput.defaults(pad);
for(const name of ['turn','forward','vertical']){
const field=form.elements[name];field.replaceChildren(new Option('Off','-1'));
for(let axis=0;axis<(pad?.axes.length||0);axis++)field.add(new Option('Axis '+axis,String(axis)));
field.value=String(config[name]);field.disabled=inputConfigurationLocked()||!pad;
const invert='invert'+name[0].toUpperCase()+name.slice(1);
form.elements[invert].checked=!!config[invert];form.elements[invert].disabled=inputConfigurationLocked()||!pad;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);
device.disabled=inputConfigurationLocked()||!pads.length;
form.elements.deadZone.disabled=inputConfigurationLocked()||!pad;
form.querySelector('[type=submit]').disabled=inputConfigurationLocked()||!pad;
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
};
$('#controller-form').onsubmit=e=>{
e.preventDefault();const form=e.currentTarget,pad=getControllers().find(pad=>controllerId(pad)===controllerKey);
if(inputConfigurationLocked()){setFeedback(form.querySelector('.form-feedback'),'Disengage before changing joystick settings.');return}
if(!pad){setFeedback(form.querySelector('.form-feedback'),'Controller disconnected. Reconnect it before saving.');return}
const config={deadZone:Number(form.elements.deadZone.value)/100};
for(const name of ['turn','forward','vertical']){
config[name]=Number(form.elements[name].value);
const invert='invert'+name[0].toUpperCase()+name.slice(1);config[invert]=form.elements[invert].checked;
}
if(!ControllerInput.read(pad,config)||!Number.isFinite(config.deadZone)||config.deadZone<0||config.deadZone>.3){
setFeedback(form.querySelector('.form-feedback'),'Choose valid axes and a dead zone between 0 and 30%.');return;
}
controllerConfig=config;try{localStorage.setItem('sub-controller:'+pad.id,JSON.stringify(config))}catch(_){}
resetInputs();closeModal($('#controller-modal'));
};
function pollController(now){
try{
if(inputMode==='joystick'){
const pads=getControllers();
if(controllerKey===null&&pads.length)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),target=pad&&ControllerInput.read(pad,controllerConfig);
let status=!navigator.getGamepads?'Controller input unavailable in this webview':!pad?(pads.length?'Selected controller unavailable · choose Setup':'Waiting for controller · press a button to detect it'):!target?'Invalid axis mapping · open Setup':!canDrive()?'Controller ready · '+pad.id:controllerNeedCenter?'Center sticks to resume':pad.id;
$('#controller-settings').disabled=inputConfigurationLocked()||!pads.length;
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(target).some(value=>value!==0)){controllerNeedCenter=false;status=pad.id}
if(!controllerNeedCenter){
const demand=controllerMotion.step(target,now);controllerWasDriving=true;
control.turn=demand.turn;control.forward=demand.forward;
window.joystick3D?.setVisual(demand.turn,-demand.forward);
setHeave(demand.vertical,false);updateReadouts();sendAxes();
}
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(status===pad?.id||status.startsWith('Controller ready'));
if($('#controller-modal').open){
const axes=pad?'Axes · '+pad.axes.map((value,index)=>index+': '+value.toFixed(2)).join(' '):'Controller disconnected';
if($('#controller-axes').textContent!==axes)$('#controller-axes').textContent=axes;
if($('#controller-device').options.length!==Math.max(1,pads.length)||[...$('#controller-device').options].some(option=>option.value&&!pads.some(pad=>controllerId(pad)===option.value)))fillControllerForm();
}
}else {$('#controller-status').dataset.quiet='true';if($('#controller-status').textContent!=='Keyboard + touch')$('#controller-status').textContent='Keyboard + touch'}
}catch(error){
if(controllerWasDriving)resetInputs();
$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable · reconnect or use keyboard';
}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
@@ -34,7 +154,7 @@ function populatePorts(ports){
if(!p)return;
const old=p.value||'';
p.innerHTML='';
const allList=[];
const seen=new Set();
@@ -84,20 +204,21 @@ function sendAxes(){
setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
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(!canDrive()||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)});
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(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();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 applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset();
@@ -120,9 +241,11 @@ function setFeedback(el,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='';
@@ -393,12 +516,14 @@ 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){
@@ -409,7 +534,7 @@ if(engageButton){
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');
@@ -417,33 +542,55 @@ if(engageButton){
}
$('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false;
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 · session logging continues':'Live · follows new messages';
$('#console-state').textContent=consolePaused?'Paused':'';
$('#console-state').hidden=!consolePaused;
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return;
if(!consoleOpen||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36;
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36;
const sides=await api('read_console',150);
if(consolePaused||!consoleDialog.open)return;
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…';
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…';
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight;
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight;
}catch(error){
$('#console-state').textContent=`Console unavailable: ${error.message}`;
}
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}
}
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750);
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
@@ -564,6 +711,7 @@ function render(s,confirmed=false){
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
const previous=view;
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
syncInputModeControls();
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
@@ -604,7 +752,7 @@ function render(s,confirmed=false){
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
@@ -617,11 +765,13 @@ function render(s,confirmed=false){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
$('#battery-fill').style.width=`${b.percent}%`;
$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)';
$('#battery-fill').style.background=b.low?'var(--battery-low)':'var(--battery-charge)';
$('.battery-card').dataset.low=String(!!b.low);
}else{
$('#battery-title').textContent='—';
$('#battery-percent').textContent='waiting';
$('#battery-fill').style.width='0';
$('.battery-card').dataset.low='false';
}
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
@@ -800,3 +950,6 @@ if(document.readyState==='loading'){
loadDefaultModel();
waitForBridge();
}
requestAnimationFrame(pollController);