Save pre-V2 motor controls and UI checkpoint

This commit is contained in:
2026-10-22 19:31:28 +02:00
parent a4d8f1b7af
commit 5e91c28f1e
15 changed files with 1041 additions and 236 deletions
+104 -55
View File
@@ -6,7 +6,8 @@ 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={}, 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 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,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='';
@@ -448,31 +449,50 @@ if(customPortBtn&&customPortInput){
}
const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
const ENGAGE_HOLD_MS=1400;
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();
// Interaction updates immediately; the short fade never gates emergency access.
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 1.4 seconds to engage (F2)':next.message;
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%');
$('#engage-label').textContent=label;
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');
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
if(suffix)engageAppendFrame=requestAnimationFrame(()=>suffix.isConnected&&suffix.classList.add('is-visible'));
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),200);
}
}
function completeEngage(){
@@ -592,7 +612,22 @@ async function refreshConsole(){
finally{consoleRefreshBusy=false}
}
setInterval(refreshConsole,750);
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
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.
@@ -623,6 +658,10 @@ function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){con
$('#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)]||{};
@@ -645,41 +684,40 @@ async function requestMotorEdit(servo,kind,value){
}
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 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 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);
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}`);
}
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');
@@ -716,7 +754,8 @@ function render(s,confirmed=false){
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);
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';
@@ -751,7 +790,7 @@ function render(s,confirmed=false){
controlStatus.classList.toggle('is-error',view.error);
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
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);
@@ -762,16 +801,25 @@ function render(s,confirmed=false){
updateReadouts();
const b=s.battery;
if(b){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
$('#battery-percent').textContent=`${Math.round(b.percent)}%`;
$('#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=`Estimated charge from voltage · ${b.cells}S${!view.known||s.battery_stale?' · stale reading':''} · Battery settings`;
$('#battery-fill').style.width=`${b.percent}%`;
$('.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';
@@ -912,20 +960,22 @@ async function boot(){
if(booted||booting||!hasDesktopApi())return;
booting=true;clearTimeout(bootRetry);
try{
// OS port discovery must not depend on model/config initialization succeeding.
populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{};
populatePorts(init.ports);
motorSettings=init.motor_settings||motorSettings;
syncMotorScalingUI();
fillBatteryForm();
fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll();
// Populate the serial selector before starting the optional model load.
// A large cached mesh must never delay an operator seeing USB COM ports.
loadDefaultModel();
// 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))}
@@ -949,9 +999,8 @@ function waitForBridge(){
addEventListener('pywebviewready',boot);
render(vehicleSnapshot);
if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
addEventListener('DOMContentLoaded',waitForBridge);
}else{
loadDefaultModel();
waitForBridge();
}