Clean up UI windows and strengthen settings workflows

This commit is contained in:
2026-09-30 15:22:09 +02:00
parent 7c85397af0
commit 0093f3f4aa
18 changed files with 2533 additions and 118 deletions
+216 -16
View File
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
link_degraded: 'Signal lost — check tether',
};
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[];
let lastReportedStatus='';
@@ -21,13 +21,14 @@ function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{cons
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&&performance.now()-lastSafetyTick<750;
const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
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},servos}}
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
window.showToast=toast;
const manualPorts=new Map();
function populatePorts(ports){
const p=$('#port');
if(!p)return;
@@ -50,6 +51,7 @@ function populatePorts(ports){
};
if(Array.isArray(ports)) ports.forEach(addPort);
manualPorts.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false;
@@ -94,7 +96,7 @@ function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.ve
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(/^(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();});
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 resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
@@ -111,28 +113,62 @@ 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)return;
if(!el||el.open)return;
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
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');
if(!el.open)el.showModal();
el.showModal();
}
function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
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',finish);
dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing');
dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
};
dialog.addEventListener('animationend',finish,{once:true});
setTimeout(finish,300);
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=>{
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`);
try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
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){
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{
const val=customPortInput.value.trim();
if(!val)return;
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){
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
p.add(new Option(`Custom (${val})`, val), 0);
p.value=val;
}
closeModal($('#link-modal'));
toast(`Selected port: ${val}`);
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
};
customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
@@ -380,6 +417,34 @@ if(engageButton){
}
$('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false;
$('#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';
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleDialog?.open||!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}`;
}
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);
// Explicit pointer ownership makes the full range rail a reliable touch target
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
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))}});
$('#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)}};
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}}
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=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
$('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{};
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
setFeedback($('#motor-feedback'));
if(demoActive){
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
else s.direction=expected;
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
}
motorEdits.set(servo,{kind,expected,started:performance.now()});
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
try{
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
}catch(error){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
}
}
function renderMotors(servos){
for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length;
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
if(signature===servoSignature)return;
servoSignature=signature;
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
for(let i=1;i<=16;i++){
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
const select=row.querySelector('select');
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
select.value=String(s.motor||0);
select.disabled=!editable||!!pending||s.function==null;
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
reverse.onclick=()=>requestMotorEdit(i,'direction');
(s.motor?list:other).append(row);
}
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
if(assigned<16)list.append(other);
}
function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
if(!status||!results||!run||!stop)return;
const quality=demoActive?null:vehicleSnapshot.link_quality;
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
const running=!!test?.running;
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
stop.hidden=!running;
stop.disabled=heavyActionBusy;
results.replaceChildren();
for(const row of test?.results||[]){
const line=document.createElement('div');line.className='heavy-result-row';
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
const stats=document.createElement('span');
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
line.append(rate,stats);results.append(line);
}
}
function render(s,confirmed=false){
vehicleSnapshot=s;
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
@@ -402,10 +566,13 @@ function render(s,confirmed=false){
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
document.body.dataset.appState=appState;
document.body.dataset.controlsEnabled=String(view.drive);
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
$('#battery-percent').textContent='waiting';
$('#battery-fill').style.width='0';
}
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||{});
}
@@ -564,8 +762,10 @@ async function boot(){
populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{};
populatePorts(init.ports);
fillBatteryForm();
fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll();