Build Argo V2 control mappings and telemetry dashboard

Add configurable Xbox motion and action bindings, frame-aware six-axis MAVLink controls, sensor logging, camera viewing and recording, and a faster startup path. Synchronize both app copies and document companion hardware setup and verification limits.
This commit is contained in:
2026-10-22 20:22:05 +02:00
parent 5e91c28f1e
commit 8ccd7689f7
38 changed files with 4295 additions and 1897 deletions
+107 -91
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={}, 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;
const control={forward:0,turn:0,vertical:0,lateral:0,pitch:0,roll:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,battery_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
let motorSettings={independent:false,customized:false,motors:Object.fromEntries(Array.from({length:12},(_,i)=>[String(i+1),{name:`Motor ${i+1}`,scale:20}]))},motorSettingsQueue=Promise.resolve();
const api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[];
@@ -25,126 +25,132 @@ let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehi
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;
let inputMode='keyboard',controllerKey=null,controllerConfig=null,controllerDraft=null,controllerNeedCenter=true,controllerWasDriving=false;
let controllerProfiles={};try{controllerProfiles=JSON.parse(localStorage.getItem('sub-v2-profiles'))||{}}catch(_){}
const controllerMotion=new ControllerInput.Motion();
let controllerActionState={},controllerActionsReady=false;
const controllerId=pad=>pad.index+':'+pad.id;
function getControllers(){try{return [...(navigator.getGamepads?.()||[])].filter(pad=>pad?.connected)}catch(_){return []}}
function loadControllerConfig(pad){
let saved=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;
let saved=controllerProfiles[pad?.id||'$xbox']||controllerProfiles.$xbox;
if(!saved&&pad)try{saved=JSON.parse(localStorage.getItem('sub-controller:'+pad.id))}catch(_){}
const config=ControllerInput.migrate(saved,pad);
return ControllerInput.validProfile(config)?config:ControllerInput.preset();
}
function selectController(pad){
resetInputs();controllerKey=controllerId(pad);controllerConfig=loadControllerConfig(pad);
resetInputs();controllerKey=pad?controllerId(pad):null;controllerConfig=loadControllerConfig(pad);
}
function setInputMode(mode){
if(mode===inputMode||inputConfigurationLocked())return false;
resetInputs();inputMode=mode;document.body.dataset.inputMode=mode;
$('#input-keyboard').setAttribute('aria-pressed',String(mode==='keyboard'));
$('#input-joystick').setAttribute('aria-pressed',String(mode==='joystick'));
$('#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');
$('#joystick-canvas').setAttribute('aria-label',mode==='joystick'?'Controller output':'Steering and throttle joystick; W A S D keys');
$('#heave').setAttribute('aria-label',mode==='joystick'?'Controller vertical output':'Vertical thrust control');
return true;
}
function inputConfigurationLocked(){
return !!(engageStart||localRequest||view.pending||vehicleSnapshot.armed===true||(vehicleSnapshot.connected&&!view.known));
}
function 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);
const locked=inputConfigurationLocked();
$('.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);
}
for(const button of [$('#input-keyboard'),$('#input-joystick'),$('#controller-settings')])button.disabled=locked;
if($('#controller-modal').open)for(const field of $('#controller-form').querySelectorAll('input,select,button[type=submit],button[data-swap]'))field.disabled=locked;
}
window.canTouchControl=()=>inputMode==='keyboard'&&canDrive();
$('#input-keyboard').onclick=()=>setInputMode('keyboard');
$('#input-joystick').onclick=()=>setInputMode('joystick');
function fillControllerForm(){
const axisLabels={forward:'Forward',lateral:'Strafe',vertical:'Vertical',turn:'Turn',pitch:'Pitch',roll:'Roll'};
function fillControllerForm(keepDraft=false){
const pads=getControllers(),device=$('#controller-device'),form=$('#controller-form');
device.replaceChildren(...pads.map(pad=>new Option(pad.id,controllerId(pad))));
if(!pads.length)device.add(new Option('No controller detected',''));
device.replaceChildren(new Option('Xbox · offline',''),...pads.map(pad=>new Option(pad.id,controllerId(pad))));
device.value=controllerKey||'';
const pad=pads.find(pad=>controllerId(pad)===controllerKey);
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;
if(!keepDraft)controllerDraft=structuredClone(controllerConfig||loadControllerConfig(pad));
const config=controllerDraft;
$('#controller-preset').value=['triggers3','sixaxis','classic'].includes(config.preset)?config.preset:'custom';
const rows=$('#controller-bindings');rows.replaceChildren();
for(const name of ControllerInput.names){
const row=document.createElement('div');row.className='binding-row';row.dataset.axis=name;
const label=document.createElement('span');label.textContent=axisLabels[name];
const meter=document.createElement('meter');meter.min=-1;meter.max=1;meter.value=0;meter.setAttribute('aria-label',axisLabels[name]+' demand');label.append(meter);row.append(label);
for(const direction of ['negative','positive']){
const select=document.createElement('select');select.name=name+'_'+direction;select.setAttribute('aria-label',axisLabels[name]+' '+direction);
for(const [value,text] of ControllerInput.sources(pad))select.add(new Option(text,value));
select.value=config.bindings[name][direction];
select.onchange=()=>{config.bindings[name][direction]=select.value;config.preset='custom';$('#controller-preset').value='custom'};
row.append(select);
}
const swap=document.createElement('button');swap.type='button';swap.dataset.swap=name;swap.className='binding-swap';swap.textContent='⇄';swap.title='Reverse '+axisLabels[name];swap.setAttribute('aria-label','Reverse '+axisLabels[name]);
swap.onclick=()=>{const item=config.bindings[name];[item.negative,item.positive]=[item.positive,item.negative];config.preset='custom';fillControllerForm(true)};row.append(swap);
const scale=document.createElement('input');scale.type='number';scale.min=0;scale.max=100;scale.step=1;scale.value=Math.round(config.bindings[name].scale*100);scale.setAttribute('aria-label',axisLabels[name]+' limit');
scale.oninput=()=>{config.bindings[name].scale=Number(scale.value)/100;config.preset='custom';$('#controller-preset').value='custom'};row.append(scale);rows.append(row);
}
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;
for(const action of ['lights','stream','record']){
const select=form.elements[action];select.replaceChildren(...ControllerInput.sources(pad).filter(([value])=>!value.startsWith('axis:')).map(([value,text])=>new Option(text,value)));
select.value=config.actions[action];select.onchange=()=>config.actions[action]=select.value;
}
form.elements.deadZone.value=Math.round(config.deadZone*100);form.elements.expo.value=Math.round(config.expo*100);
form.elements.deadZone.oninput=()=>config.deadZone=Number(form.elements.deadZone.value)/100;
form.elements.expo.oninput=()=>config.expo=Number(form.elements.expo.value)/100;
syncInputModeControls();
}
$('#controller-device').onchange=e=>{
if(inputConfigurationLocked())return;
const pad=getControllers().find(pad=>controllerId(pad)===e.target.value);
if(pad){selectController(pad);fillControllerForm()}
selectController(getControllers().find(pad=>controllerId(pad)===e.target.value));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'));
$('#controller-preset').onchange=e=>{if(e.target.value==='custom')return;controllerDraft=ControllerInput.preset(e.target.value);fillControllerForm(true)};
$('#controller-form').onsubmit=async e=>{
e.preventDefault();const form=e.currentTarget,feedback=form.querySelector('.form-feedback');
if(inputConfigurationLocked()){setFeedback(feedback,'Disengage first');return}
if(!ControllerInput.validProfile(controllerDraft)){setFeedback(feedback,'Check inputs');return}
const pad=getControllers().find(pad=>controllerId(pad)===controllerKey),device=pad?.id||'$xbox';
try{
const clean=bridgeReady?await api('save_controller_profile',device,controllerDraft):structuredClone(controllerDraft);
controllerProfiles[device]=clean;localStorage.setItem('sub-v2-profiles',JSON.stringify(controllerProfiles));controllerConfig=clean;
resetInputs();closeModal($('#controller-modal'));toast('Mapping saved');
}catch(error){setFeedback(feedback,error.message||String(error))}
};
function pollController(now){
try{
if(inputMode==='joystick'){
if(inputMode==='joystick'||$('#controller-modal').open){
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(controllerKey===null&&pads.length&&!$('#controller-modal').open)selectController(pads[0]);
const pad=pads.find(pad=>controllerId(pad)===controllerKey),raw=pad&&ControllerInput.read(pad,controllerConfig||loadControllerConfig(pad));
const target=raw&&(window.V2?.filterDemand(raw)||raw);
let status=!pad?'No controller':!raw?'Check mapping':controllerNeedCenter&&canDrive()?'Center controls':'Controller ready';
if(inputMode==='joystick'){
if(!target||!canDrive()){
if(controllerWasDriving)resetInputs();controllerNeedCenter=true;controllerWasDriving=false;
}else{
if(controllerNeedCenter&&!Object.values(raw).some(value=>value!==0)){controllerNeedCenter=false;status='Controller ready'}
if(!controllerNeedCenter){
controllerWasDriving=true;
Object.assign(control,target);window.joystick3D?.setVisual(target.turn,-target.forward);
setHeave(target.vertical,false);updateReadouts();sendAxes();
}
}
const active=ControllerInput.actions(pad,controllerConfig);
if(!Object.values(active).some(Boolean))controllerActionsReady=true;
if(controllerActionsReady&&inputFocused&&!document.hidden&&!document.querySelector('dialog[open]'))for(const [action,pressed] of Object.entries(active))
if(pressed&&!controllerActionState[action])window.V2?.action(action);
controllerActionState=active;
}
if($('#controller-modal').open){
const demand=pad&&ControllerInput.read(pad,controllerDraft);
for(const meter of $('#controller-bindings').querySelectorAll('meter'))meter.value=demand?.[meter.closest('[data-axis]').dataset.axis]||0;
$('#controller-axes').textContent=pad?.mapping==='standard'?'Xbox layout':pad?'Raw inputs':'Xbox · offline';
}
if($('#controller-status').textContent!==status)$('#controller-status').textContent=status;
$('#controller-status').dataset.quiet=String(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';
}
$('#controller-status').dataset.quiet=String(!!pad&&!!raw);
}else{$('#controller-status').dataset.quiet='true';$('#controller-status').textContent='Keyboard + touch'}
}catch(error){if(controllerWasDriving)resetInputs();$('#controller-status').dataset.quiet='false';$('#controller-status').textContent='Controller unavailable'}
requestAnimationFrame(pollController);
}
document.body.dataset.inputMode=inputMode;
requestAnimationFrame(pollController);
function makeDemoState(){const 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 makeDemoState(){const frame=window.V2?.station().frame??4,count=({0:6,1:6,2:8,3:8,4:3,5:4,6:5})[frame],servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false,current_a:3.2,consumed_mah:320,remaining_mah:5180,consumed_source:'Demo'},capabilities:{frame,firmware:[4,5,3]},accessories:{},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
@@ -156,7 +162,7 @@ function populatePorts(ports){
const old=p.value||'';
p.innerHTML='';
const allList=[];
const allList=[{device:'udpin:0.0.0.0:14550',label:'Network · UDP 14550',type:'network'}];
const seen=new Set();
const addPort=item=>{
@@ -194,7 +200,7 @@ function sendAxes(){
axisTimer=0;
if(!canDrive()||demoActive||axisBusy)return;
lastAxisSend=performance.now();axisBusy=true;
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence,control.lateral,control.pitch,control.roll))
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
};
const wait=50-(performance.now()-lastAxisSend);
@@ -203,7 +209,7 @@ function sendAxes(){
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
// the configured wire rate and neutralizes if the UI stops renewing its input.
setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ 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);}
function updateReadouts(){ window.V2?.updateDemand(control);if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!window.canTouchControl())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
@@ -216,8 +222,8 @@ heave.addEventListener('pointerdown',e=>{if(!window.canTouchControl()||heavePoin
function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const 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 applyKeys(){if(inputMode!=='keyboard'||!canDrive()){pressedKeys.clear();return;}const extra=window.V2?.filterDemand({lateral:(pressedKeys.has('l')?1:0)-(pressedKeys.has('j')?1:0),pitch:(pressedKeys.has('k')?1:0)-(pressedKeys.has('i')?1:0),roll:(pressedKeys.has('o')?1:0)-(pressedKeys.has('u')?1:0)});Object.assign(control,extra);updateReadouts();sendAxes();const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerfjlikuo'.includes(k)&&k.length===1){if(inputMode!=='keyboard'||!canDrive())return;e.preventDefault();if(e.repeat)return;if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){
controllerMotion.reset();controllerNeedCenter=true;controllerWasDriving=false;
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
@@ -226,7 +232,7 @@ function resetInputs(){
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
hknob.classList.remove('pressed','released');
control.forward=control.turn=control.vertical=0;setHeave(0,false);
for(const axis of Object.keys(control))control[axis]=0;setHeave(0,false);window.V2?.updateDemand(control);controllerActionState={};controllerActionsReady=false;
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
}
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
@@ -750,6 +756,7 @@ function render(s,confirmed=false){
const previous=view;
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
syncInputModeControls();
window.V2?.render(s);
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
@@ -964,6 +971,9 @@ async function boot(){
batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{};
motorSettings=init.motor_settings||motorSettings;
controllerProfiles=init.controller_profiles||controllerProfiles;
controllerConfig=null;
window.V2?.setStation(init.station_settings||{});
syncMotorScalingUI();
fillBatteryForm();
fillLinkForm();
@@ -996,6 +1006,15 @@ function waitForBridge(){
if(hasDesktopApi())boot();
setTimeout(waitForBridge,250);
}
window.V2.init({
api,bridge:()=>bridgeReady,demo:()=>demoActive,toast,open:openModal,close:closeModal,
locked:inputConfigurationLocked,reset:resetInputs,touch:()=>window.canTouchControl(),demand:()=>control,
axis:(name,value)=>{control[name]=value;updateReadouts();sendAxes()},
scaling:allowed=>{const toggle=$('#motor-scale-independent');toggle.disabled=!allowed||inputConfigurationLocked();toggle.title=allowed?'Independent scales':'SimpleROV 3 only';if(!allowed&&motorSettings.independent){motorSettings.independent=false;syncMotorScalingUI();servoSignature=''}},
demoAccessory:(name,enabled)=>{if(demoState){demoState.accessories??={};demoState.accessories[name]={requested:enabled,accepted:true,demo:true};render(demoState)}},
previewFrame:(frame,count)=>{if(demoState){demoState.capabilities={frame,firmware:[4,5,3]};for(let i=1;i<=16;i++)demoState.servos[String(i)]={function:i<=count?32+i:0,label:i<=count?`Motor ${i}`:'Unassigned',motor:i<=count?i:null,direction:i<=count?1:null};servoSignature='';render(demoState)}}
});
addEventListener('pywebviewready',boot);
render(vehicleSnapshot);
if(document.readyState==='loading'){
@@ -1003,6 +1022,3 @@ if(document.readyState==='loading'){
}else{
waitForBridge();
}
requestAnimationFrame(pollController);
+73 -1
View File
@@ -10,6 +10,7 @@
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
}
function read(pad,config){
if(config?.version===2)return readProfile(pad,config);
if(!pad?.connected||!pad.axes)return null;
const axis=(index,invert)=>{
if(index===-1)return 0;
@@ -48,6 +49,77 @@
return demand;
}
}
root.ControllerInput={deadZone,defaults,read,Motion};
const names=['forward','lateral','vertical','turn','pitch','roll'];
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
function preset(name='triggers3'){
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
bindings.forward=pair('button:6','button:7');
bindings.vertical=pair('button:0','button:1');
bindings.turn=pair('axis:0:-','axis:0:+');
if(name==='sixaxis'){
bindings.lateral=pair('axis:2:-','axis:2:+');
bindings.pitch=pair('axis:1:-','axis:1:+');
bindings.roll=pair('button:4','button:5');
}else if(name==='classic'){
bindings.forward=pair('axis:1:+','axis:1:-');
bindings.vertical=pair('axis:3:+','axis:3:-');
}
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
}
function migrate(config,pad){
if(config?.version===2)return structuredClone(config);
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
const result=preset('classic');
for(const axis of ['forward','vertical','turn']){
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
result.bindings[axis]=Number.isInteger(index)&&index>=0?
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
}
result.deadZone=config.deadZone??.08;
result.actions={lights:'off',stream:'off',record:'off'};
return result;
}
function validProfile(config){
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
}
function inputValue(pad,source,zone){
if(source==='off')return 0;
const parts=source.split(':'),index=Number(parts[1]);
if(parts[0]==='button'){
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
}
const value=pad.axes?.[index];
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
}
function readProfile(pad,config){
if(!pad?.connected||!validProfile(config))return null;
const result={};
for(const name of names){
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
if(negative===null||positive===null)return null;
const value=clamp(positive-negative);
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
}
return result;
}
function actions(pad,config){
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
}
function sources(pad){
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
return choices;
}
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
if(typeof module!=='undefined')module.exports=root.ControllerInput;
})(typeof window!=='undefined'?window:globalThis);
+229
View File
@@ -0,0 +1,229 @@
/* Video stays in the browser's media pipeline; no frames cross the control bridge. */
(()=>{
const $=id=>document.getElementById(id);
const frames={0:['BlueROV1',6,['forward','lateral','vertical','turn','pitch','roll']],1:['Vectored',6,['forward','lateral','vertical','turn','roll']],2:['Full 6DOF',8,['forward','lateral','vertical','turn','pitch','roll']],3:['6DOF 90°',8,['forward','lateral','vertical','turn','pitch','roll']],4:['SimpleROV 3',3,['forward','vertical','turn']],5:['SimpleROV 4',4,['forward','vertical','turn','roll']],6:['SimpleROV 5',5,['forward','lateral','vertical','turn','roll']]};
let station={frame:4,video_mode:'whep',video_url:'',lights_relay:-1,camera_relay:-1};
try{station={...station,...JSON.parse(localStorage.getItem('sub-v2-station'))}}catch(_){}
let host,snapshot={},workspace='drive',streaming=false,connecting=false,generation=0,peer=null,sessionUrl=null,abort=null;
let recorder=null,recordToken=null,recordQueue=Promise.resolve(),recordChunks=[],recordBytes=0,ownedStream=null,recordError=null,saving=false;
let demoFrame=0,lastPaint=0,streamStarted=0,auxBusy=false,streamTimer=0,capabilitySignature='',demoLastClock=-1;
const notify=(message,error=false)=>host?.toast(message,error);
const call=(name,...args)=>host.api(name,...args);
const demo=()=>!!host?.demo();
const frame=()=>frames[(demo()||!snapshot.connected)?station.frame:snapshot.capabilities?.frame]||frames[4];
function supported(){
let axes=frame()[2];
const firmware=snapshot.capabilities?.firmware;
if(snapshot.connected&&!demo()&&!(firmware&&(firmware[0]>4||firmware[0]===4&&(firmware[1]===5||firmware[1]>=8))))axes=axes.filter(axis=>axis!=='pitch'&&axis!=='roll');
return axes;
}
function filterDemand(demand){return Object.fromEntries(Object.entries(demand).map(([name,value])=>[name,supported().includes(name)?value:0]))}
function view(name){
if(name===workspace)return;
host.reset();workspace=name;document.body.dataset.view=name;
$('dashboard').hidden=name!=='dashboard';document.querySelector('.primary-control-zone').hidden=name!=='drive';
$('view-drive').setAttribute('aria-pressed',String(name==='drive'));$('view-dashboard').setAttribute('aria-pressed',String(name==='dashboard'));
}
function fillStation(){
const form=$('station-form');
for(const [name,value] of Object.entries(station))if(form.elements[name])form.elements[name].value=value;
for(const field of form.querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
}
function state(text){$('camera-state').textContent=text;$('camera-empty').hidden=false}
function showStream(){
clearTimeout(streamTimer);connecting=false;
$('camera-empty').hidden=true;$('camera-record').disabled=station.video_mode==='mjpeg'&&!demo();
$('camera-badge').textContent=demo()?'Demo':'Stream';streamStarted=performance.now();demoLastClock=-1;$('stream-clock').textContent='00:00';
}
async function startStream(){
if(streaming||connecting)return;
if(!demo()&&!station.video_url){host.open('station-modal');return}
const current=++generation;connecting=true;state('Connecting');$('camera-toggle').textContent='Stop';
clearTimeout(streamTimer);streamTimer=setTimeout(()=>{if(current===generation)void failStream('Stream timeout')},10000);
try{
if(demo()){
$('camera-demo').hidden=false;streaming=true;showStream();demoFrame=requestAnimationFrame(drawDemo);
}else if(station.video_mode==='mjpeg'){
const img=$('camera-image');img.onload=()=>{if(current!==generation)return;streaming=true;img.hidden=false;showStream()};
img.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};img.src=station.video_url;
}else if(station.video_mode==='video'){
const video=$('camera-video');video.onerror=()=>{if(current===generation)void failStream('Stream unavailable')};video.onended=()=>{if(current===generation)void stopStream()};video.src=station.video_url;video.hidden=false;await video.play();
if(current!==generation)return;streaming=true;showStream();
}else{
abort=new AbortController();const timeout=setTimeout(()=>abort?.abort(),10000);
try{
peer=new RTCPeerConnection({iceServers:[]});const connection=peer;
connection.addTransceiver('video',{direction:'recvonly'});
connection.ontrack=event=>{if(current!==generation)return;$('camera-video').srcObject=event.streams[0]||new MediaStream([event.track]);$('camera-video').hidden=false;void $('camera-video').play().catch(()=>{});streaming=true;showStream()};
connection.onconnectionstatechange=()=>{if(current===generation&&connection.connectionState==='failed')void failStream('Camera disconnected')};
await connection.setLocalDescription(await connection.createOffer());
if(connection.iceGatheringState!=='complete')await new Promise(resolve=>{
const timer=setTimeout(resolve,2000);connection.addEventListener('icegatheringstatechange',()=>{if(connection.iceGatheringState==='complete'){clearTimeout(timer);resolve()}},{once:true});
});
if(current!==generation)return;
const response=await fetch(station.video_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:connection.localDescription.sdp,signal:abort.signal});
if(!response.ok)throw new Error('WHEP '+response.status);
const location=response.headers.get('Location');let resource=location?new URL(location,station.video_url).href:null;
if(resource&&new URL(resource).origin!==new URL(station.video_url).origin)resource=null;
if(current!==generation){if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});return}
sessionUrl=resource;
const sdp=await response.text();if(current!==generation)return;
await connection.setRemoteDescription({type:'answer',sdp});
}finally{clearTimeout(timeout)}
}
}catch(error){if(current===generation){await stopStream();state('Unavailable');notify(error.message||'Stream unavailable',true)}}
finally{if(current===generation&&streaming)connecting=false}
}
async function failStream(message){await stopStream();state(message);notify(message,true)}
async function stopStream(){
++generation;connecting=false;streaming=false;abort?.abort();abort=null;
clearTimeout(streamTimer);
if(recorder&&recorder.state!=='inactive')recorder.stop();
peer?.close();peer=null;
if(sessionUrl){void fetch(sessionUrl,{method:'DELETE'}).catch(()=>{});sessionUrl=null}
cancelAnimationFrame(demoFrame);demoFrame=0;
const video=$('camera-video');video.onerror=video.onended=null;video.pause();video.srcObject=null;video.removeAttribute('src');video.load();video.hidden=true;
const img=$('camera-image');img.onload=img.onerror=null;img.removeAttribute('src');img.hidden=true;
$('camera-demo').hidden=true;$('camera-record').disabled=true;$('camera-toggle').textContent='Start';$('stream-clock').textContent='';state('Camera off');
}
function drawDemo(now){
if(!streaming||!demo())return;
demoFrame=requestAnimationFrame(drawDemo);
if(workspace!=='dashboard'||document.hidden||now-lastPaint<40)return;lastPaint=now;
const seconds=Math.floor((now-streamStarted)/1000);
if(seconds!==demoLastClock){demoLastClock=seconds;$('stream-clock').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')}
const canvas=$('camera-demo'),rect=canvas.getBoundingClientRect();
const width=Math.max(320,Math.round(rect.width)),height=Math.max(180,Math.round(rect.height));
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
const c=canvas.getContext('2d'),gradient=c.createLinearGradient(0,0,0,height);gradient.addColorStop(0,'#28444a');gradient.addColorStop(1,'#101e25');c.fillStyle=gradient;c.fillRect(0,0,width,height);
const light=c.createRadialGradient(width*.52,height*.62,4,width*.52,height*.62,width*.6);light.addColorStop(0,'#90aaa324');light.addColorStop(1,'#00000000');c.fillStyle=light;c.fillRect(0,0,width,height);
c.strokeStyle='#9eb2a92a';c.lineWidth=1;
for(let i=-4;i<8;i++){c.beginPath();c.moveTo(width*.5+i*16,height*.45);c.lineTo(width*.5+i*110,height);c.stroke()}
for(let i=0;i<8;i++){const y=height*.45+(i/8)**2*height*.55;c.beginPath();c.moveTo(0,y);c.lineTo(width,y);c.stroke()}
const pipe=c.createLinearGradient(0,height*.58,0,height*.76);pipe.addColorStop(0,'#a1afa0');pipe.addColorStop(.4,'#6e8980');pipe.addColorStop(1,'#263e3d');c.fillStyle=pipe;c.fillRect(width*.16,height*.58,width*.72,height*.18);
c.strokeStyle='#b3c2b166';c.strokeRect(width*.16,height*.58,width*.72,height*.18);
c.fillStyle='#c3d7c977';for(let i=0;i<32;i++){const x=(i*89.7+Math.sin(now/5000+i)*9)%width,y=(i*37.9+now*.004)%height;c.fillRect(x,y,1,1)}
c.strokeStyle='#e1f0e477';c.beginPath();c.moveTo(width/2-10,height/2);c.lineTo(width/2+10,height/2);c.moveTo(width/2,height/2-10);c.lineTo(width/2,height/2+10);c.stroke();
}
async function toggleRecord(){
if(saving)return;
if(recorder&&recorder.state!=='inactive'){recorder.stop();return}
if(!streaming)return;
try{
const stream=demo()?$('camera-demo').captureStream(24):$('camera-video').srcObject||$('camera-video').captureStream?.();
if(!stream?.getVideoTracks().length)throw new Error('Recording unavailable');
ownedStream=demo()?stream:null;recordToken=host.bridge()?await call('recording_begin'):null;recordQueue=Promise.resolve();recordChunks=[];recordBytes=0;recordError=null;
const mime=['video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type));
recorder=new MediaRecorder(stream,mime?{mimeType:mime,videoBitsPerSecond:4000000}:undefined);
recorder.ondataavailable=event=>{
if(!event.data.size)return;
if(recordToken){const token=recordToken;recordQueue=recordQueue.then(async()=>{
if(recordError)return;
const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=reject;reader.readAsDataURL(event.data)});
await call('recording_chunk',token,data);
}).catch(error=>{recordError=error;if(recorder?.state==='recording')recorder.stop()})}
else{recordChunks.push(event.data);recordBytes+=event.data.size;if(recordBytes>256*1024*1024&&recorder.state==='recording')recorder.stop()}
};
recorder.onstop=async()=>{
saving=true;$('camera-record').textContent='Saving';$('recording-badge').hidden=true;
try{
await recordQueue;
if(recordToken){const path=await call('recording_end',recordToken);notify(recordError?'Recording interrupted':'Video saved',!!recordError);if(path)$('camera-record').title=path}
else{const blob=new Blob(recordChunks,{type:recorder.mimeType}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='argo-'+Date.now()+'.webm';link.click();setTimeout(()=>URL.revokeObjectURL(url),60000);notify('Video saved')}
}catch(error){notify(error.message||'Save failed',true)}
finally{ownedStream?.getTracks().forEach(track=>track.stop());ownedStream=null;recordToken=null;recordChunks=[];saving=false;$('camera-record').textContent='Record';$('camera-record').setAttribute('aria-pressed','false');recorder=null}
};
recorder.start(1000);$('camera-record').textContent='Stop record';$('camera-record').setAttribute('aria-pressed','true');$('recording-badge').hidden=false;
}catch(error){if(recordToken)await call('recording_end',recordToken).catch(()=>{});recordToken=null;notify(error.message||'Recording unavailable',true)}
}
async function accessory(name){
if(auxBusy||!snapshot.connected)return;
const relay=station[name+'_relay'];if(relay<0){host.open('station-modal');return}
auxBusy=true;
try{
const previous=snapshot.accessories?.[name],enabled=!(previous?.accepted&&previous.requested);
if(demo())host.demoAccessory(name,enabled);else await call('set_accessory',name,enabled);
if(name==='camera'&&!enabled)await stopStream();
}catch(error){notify(error.message||'Accessory unavailable',true)}finally{auxBusy=false}
}
function updateDemand(demand){
if(!host)return;
const axes=supported();$('extra-controls').hidden=!axes.some(axis=>['lateral','pitch','roll'].includes(axis));
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const axis=label.dataset.motion;label.hidden=!axes.includes(axis);label.querySelector('input').value=Math.round((demand[axis]||0)*100);
label.querySelector('input').disabled=!host.touch();label.querySelector('output').value=Math.round((demand[axis]||0)*100);
}
}
function render(s){
snapshot=s;
if(!host)return;
const signature=String(!!s.connected)+':'+supported().join(',');
if(capabilitySignature&&signature!==capabilitySignature)host.reset();
capabilitySignature=signature;
host.scaling((demo()||!s.connected)?station.frame===4:s.capabilities?.frame===4);
if($('station-modal').open)for(const field of $('station-form').querySelectorAll('input,select,button[type=submit]'))field.disabled=host.locked();
const telemetry=demo()&&s.connected?{
attitude:{values:{heading:124,pitch:4.2,roll:-2.1},stale:false},depth:{values:{depth_m:1.8},stale:false},
imu:{values:{ax:.12,ay:-.08,az:9.78,gx:.01,gy:.02,gz:0,mx:.21,my:.04,mz:.43},stale:false},pressure:{values:{pressure_hpa:1190,temperature_c:18.6},stale:false}
}:s.telemetry||{};
const value=(group,key)=>s.connected&&!telemetry[group]?.stale?telemetry[group]?.values?.[key]:null;
const format=(value,unit,digits=1)=>Number.isFinite(value)?value.toFixed(digits)+unit:'—';
const show=(id,number,unit,digits)=>{$(id).textContent=format(number,unit,digits);$(id).parentElement.dataset.stale=String(!Number.isFinite(number))};
show('live-depth',value('depth','depth_m'),' m',2);show('live-heading',value('attitude','heading'),'°',0);show('live-pitch',value('attitude','pitch'),'°',1);show('live-roll',value('attitude','roll'),'°',1);
const battery=s.battery||{},fresh=s.connected&&!s.battery_stale;
const current=fresh&&!battery.current_stale?battery.current_a:null;
show('live-current',current,' A',1);show('live-power',fresh&&Number.isFinite(current)&&Number.isFinite(battery.voltage)?current*battery.voltage:null,' W',1);
show('live-charge',fresh?battery.consumed_mah:null,' mAh',0);
for(const [id,keys,unit] of [['live-accel',['ax','ay','az'],'m/s²'],['live-gyro',['gx','gy','gz'],'rad/s'],['live-mag',['mx','my','mz'],'G']]){
const values=keys.map(key=>value('imu',key));$(id).textContent=values.every(Number.isFinite)?values.map(value=>value.toFixed(2)).join(' / ')+' '+unit:'—';
}
show('live-temperature',value('pressure','temperature_c'),' °C',1);show('live-pressure',value('pressure','pressure_hpa'),' hPa',1);
$('sensor-state').textContent=demo()&&s.connected?'Demo':!s.connected?'Offline':Object.values(telemetry).some(group=>!group.stale)?'Receiving':'No data';
$('detected-frame').textContent=s.connected&&!demo()?(frames[s.capabilities?.frame]?.[0]||'Detecting'):demo()?'Demo':'Offline';
for(const [name,id] of [['lights','lights-toggle'],['camera','camera-power']]){
const item=s.accessories?.[name],pending=item&&item.accepted===null&&!item.error;
$(id).disabled=!s.connected||station[name+'_relay']<0||!!pending;$(id).setAttribute('aria-pressed',String(!!(item?.accepted&&item.requested)));
$(id).title=item?.error||(pending?'Awaiting ACK':station[name+'_relay']<0?'Configure relay':'Relay '+station[name+'_relay']);
}
if(streaming)$('stream-clock').textContent=new Date(performance.now()-streamStarted).toISOString().slice(14,19);
$('sensor-record').setAttribute('aria-pressed',String(!!s.sensor_recording));$('sensor-record').textContent=s.sensor_recording?'Stop log':'Log data';
$('sensor-record').disabled=!s.connected||demo();
updateDemand(host.demand());
}
function action(name){if(name==='stream')void(streaming||connecting?stopStream():startStream());else if(name==='record')void toggleRecord();else if(name==='lights')void accessory('lights')}
function init(callbacks){
host=callbacks;
$('view-drive').onclick=()=>view('drive');$('view-dashboard').onclick=()=>view('dashboard');
$('camera-toggle').onclick=()=>action('stream');$('camera-record').onclick=()=>action('record');$('lights-toggle').onclick=()=>accessory('lights');$('camera-power').onclick=()=>accessory('camera');
$('sensor-record').onclick=async()=>{try{if(demo())throw new Error('Live telemetry required');if(!host.bridge())throw new Error('Connect desktop');const path=await call('sensor_record',!snapshot.sensor_recording);if(path)$('sensor-record').title=path}catch(error){notify(error.message,true)}};
for(const name of ['lights_relay','camera_relay']){
const select=$('station-form').elements[name];select.add(new Option('Off','-1'));for(let index=0;index<16;index++)select.add(new Option('Relay '+index,String(index)));
}
const stationObserver=new MutationObserver(()=>{if($('station-modal').open)fillStation()});stationObserver.observe($('station-modal'),{attributes:true,attributeFilter:['open']});
document.querySelector('[data-open=station-modal]').addEventListener('click',fillStation);
$('station-form').onsubmit=async event=>{
event.preventDefault();const form=event.currentTarget,feedback=form.querySelector('.form-feedback');
if(host.locked()){feedback.textContent='Disengage first';feedback.hidden=false;return}
const config=Object.fromEntries(new FormData(form));for(const name of ['frame','lights_relay','camera_relay'])config[name]=Number(config[name]);
try{
if(config.video_url){const url=new URL(config.video_url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw new Error('Use an HTTP stream URL')}
if(config.lights_relay>=0&&config.lights_relay===config.camera_relay)throw new Error('Use separate relays');
if(streaming||connecting)await stopStream();
station=host.bridge()?await call('save_station_settings',config):config;localStorage.setItem('sub-v2-station',JSON.stringify(station));
host.previewFrame(station.frame,frames[station.frame][1]);host.reset();host.close($('station-modal'));notify('Vehicle saved');render(snapshot);
}catch(error){feedback.textContent=error.message||String(error);feedback.hidden=false}
};
for(const label of $('extra-controls').querySelectorAll('[data-motion]')){
const input=label.querySelector('input'),axis=label.dataset.motion;
input.oninput=()=>{if(host.touch())host.axis(axis,Number(input.value)/100)};
const release=()=>{if(host.touch())host.axis(axis,0);input.value=0};
input.onpointerup=release;input.onpointercancel=release;input.onblur=release;input.onkeyup=release;
}
for(const [name,text,value] of [['attitude_hz','Attitude · Hz',2],['depth_hz','Depth · Hz',2],['imu_hz','IMU · Hz',1],['pressure_hz','Pressure · Hz',1]]){
const label=document.createElement('label'),span=document.createElement('span'),select=document.createElement('select');span.textContent=text;select.name=name;
for(const rate of [0,1,2,5,10])select.add(new Option(rate===0?'Off':String(rate),String(rate)));select.value=String(value);label.append(span,select);$('link-form').querySelectorAll('.form-grid')[1].append(label);
}
addEventListener('pagehide',()=>void stopStream());document.body.dataset.view='drive';render(snapshot);
}
window.V2={init,render,filterDemand,action,updateDemand,station:()=>station,setStation:config=>{station={...station,...config};render(snapshot)}};
})();
+35 -15
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20261017-engage-center-v2">
<title>Argo V2</title>
<link rel="stylesheet" href="styles.css?v=20261022-v2">
<link rel="stylesheet" href="v2.css?v=20261022-v2">
</head>
<body>
<main class="app-shell">
@@ -34,7 +35,7 @@
<!-- Telemetry / Status Zone -->
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
<div class="deck-card model-card">
<div class="rov-stage" aria-label="Top view of three-thruster ROV">
<div class="rov-stage" aria-label="Vehicle model">
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools">
@@ -71,7 +72,7 @@
<section class="controls-workspace" aria-label="Primary Vehicle Controls">
<!-- Frameless Drag & Window Controls Header -->
<div class="workspace-topbar">
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal" hidden>Setup</button></div>
<div class="input-mode-controls"><div class="input-mode-switch" role="group" aria-label="Control input"><button type="button" id="input-keyboard" aria-pressed="true">Keyboard</button><button type="button" id="input-joystick" aria-pressed="false">Joystick</button></div><button type="button" id="controller-settings" class="controller-settings" data-open="controller-modal">Mapping</button></div>
<div class="workspace-drag-area pywebview-drag-region"></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
@@ -80,7 +81,18 @@
</div>
</div>
<div class="workspace-nav"><div class="view-switch" role="group" aria-label="Workspace"><button id="view-drive" type="button" aria-pressed="true">Drive</button><button id="view-dashboard" type="button" aria-pressed="false">Dashboard</button></div><span class="version-mark">V2</span><button class="quiet-button" data-open="station-modal" type="button">Vehicle</button></div>
<p id="controller-status" class="controller-status" role="status">Keyboard + touch</p>
<section id="dashboard" class="dashboard" aria-label="Vehicle dashboard" hidden>
<div class="camera-stage">
<video id="camera-video" muted autoplay playsinline hidden></video><img id="camera-image" alt="Camera stream" hidden><canvas id="camera-demo" aria-label="Simulated inspection camera" hidden></canvas>
<div id="camera-empty" class="camera-empty"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="12" width="27" height="24" rx="6"/><path d="m32 20 11-6v20l-11-6"/></svg><span id="camera-state">Camera off</span></div>
<div class="camera-overlay"><span id="camera-badge">Preview</span><span id="recording-badge" hidden>● REC</span><span id="stream-clock"></span></div>
</div>
<div class="dashboard-actions"><button id="camera-toggle" type="button">Start</button><button id="camera-record" type="button" aria-pressed="false" disabled>Record</button><button id="lights-toggle" type="button" aria-pressed="false" disabled>Lights</button><button id="camera-power" type="button" aria-pressed="false" disabled>Camera power</button><button id="sensor-record" type="button" aria-pressed="false">Log data</button></div>
<div class="telemetry-strip" aria-label="Live telemetry"><div><span>Depth</span><strong id="live-depth">—</strong></div><div><span>Heading</span><strong id="live-heading">—</strong></div><div><span>Current</span><strong id="live-current">—</strong></div><div><span>Pitch</span><strong id="live-pitch">—</strong></div><div><span>Roll</span><strong id="live-roll">—</strong></div><div><span>Power</span><strong id="live-power">—</strong></div></div>
<details class="sensor-details"><summary>Sensors <span id="sensor-state">Offline</span></summary><div class="sensor-values"><div><span>Acceleration</span><strong id="live-accel">—</strong></div><div><span>Gyroscope</span><strong id="live-gyro">—</strong></div><div><span>Magnetometer</span><strong id="live-mag">—</strong></div><div><span>Temperature</span><strong id="live-temperature">—</strong></div><div><span>Pressure</span><strong id="live-pressure">—</strong></div><div><span>Charge used</span><strong id="live-charge">—</strong></div></div></details>
</section>
<section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone">
@@ -146,6 +158,7 @@
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
</div>
<div id="extra-controls" class="extra-controls" hidden><label data-motion="lateral"><span>Strafe</span><input type="range" min="-100" max="100" value="0" aria-label="Strafe demand"><output>0</output></label><label data-motion="pitch"><span>Pitch</span><input type="range" min="-100" max="100" value="0" aria-label="Pitch demand"><output>0</output></label><label data-motion="roll"><span>Roll</span><input type="range" min="-100" max="100" value="0" aria-label="Roll demand"><output>0</output></label></div>
</section>
</section>
<aside id="console-dock" class="console-dock" aria-labelledby="console-title" hidden>
@@ -218,28 +231,35 @@
<div class="motor-scale-heading"><span>Output</span><label class="switch-control"><input id="motor-scale-independent" type="checkbox"><span class="switch-track" aria-hidden="true"></span><span>Independent</span></label></div>
<div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="controller-modal" class="modal" aria-labelledby="controller-title">
<dialog id="controller-modal" class="modal controller-modal" aria-labelledby="controller-title">
<button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="controller-title">Joystick setup</h2>
<form id="controller-form" title="Center sticks after changing input mode or reconnecting before movement resumes.">
<label><span>Controller</span><select id="controller-device"></select></label>
<p id="controller-axes" class="note">Move a stick or press a button to detect your controller.</p>
<div class="controller-mapping-head"><span>Movement</span><span>Axis</span><span>Invert</span></div>
<div class="controller-mapping-row"><span>Turn</span><select name="turn" aria-label="Turn axis"></select><input name="invertTurn" type="checkbox" aria-label="Invert Turn"></div>
<div class="controller-mapping-row"><span>Forward / back</span><select name="forward" aria-label="Forward / back axis"></select><input name="invertForward" type="checkbox" aria-label="Invert Forward / back"></div>
<div class="controller-mapping-row"><span>Rise / dive</span><select name="vertical" aria-label="Rise / dive axis"></select><input name="invertVertical" type="checkbox" aria-label="Invert Rise / dive"></div>
<label class="controller-deadzone"><span>Dead zone · %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label>
<form id="controller-form">
<div class="form-grid two"><label><span>Controller</span><select id="controller-device"></select></label><label><span>Preset</span><select id="controller-preset"><option value="triggers3">Triggers · 3</option><option value="sixaxis">Six axes</option><option value="classic">Classic sticks</option><option value="custom">Custom</option></select></label></div>
<p id="controller-axes" class="note" hidden>Xbox · offline</p>
<div class="binding-head"><span>Motion</span><span>Negative</span><span>Positive</span><span></span><span>Limit %</span></div>
<div id="controller-bindings"></div>
<div class="form-grid two mapping-tuning"><label><span>Dead zone %</span><input name="deadZone" type="number" min="0" max="30" step="1" required></label><label><span>Expo %</span><input name="expo" type="number" min="0" max="100" step="1" required></label></div>
<details class="mapping-actions"><summary>Buttons</summary><div class="form-grid three"><label><span>Lights</span><select name="lights"></select></label><label><span>Stream</span><select name="stream"></select></label><label><span>Record</span><select name="record"></select></label></div></details>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save mapping</button></div>
</form>
</dialog>
<dialog id="station-modal" class="modal station-modal" aria-labelledby="station-title"><button class="modal-close" type="button" data-close aria-label="Close window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button><h2 id="station-title">Vehicle</h2><form id="station-form">
<div class="form-grid two"><label><span>Preview frame</span><select name="frame"><option value="4">SimpleROV · 3</option><option value="0">BlueROV1 · 6</option><option value="1">Vectored · 6</option><option value="2">Full 6DOF · 8</option><option value="3">6DOF 90° · 8</option><option value="5">SimpleROV · 4</option><option value="6">SimpleROV · 5</option></select></label><div class="detected-frame"><span>FC frame</span><strong id="detected-frame">Offline</strong></div></div>
<section class="settings-section"><h3>Camera</h3><label><span>Stream type</span><select name="video_mode"><option value="whep">WebRTC · WHEP</option><option value="video">HTTP video</option><option value="mjpeg">MJPEG</option></select></label><label><span>Stream URL</span><input name="video_url" type="url" placeholder="http://192.168.2.2:8889/cam/whep" autocomplete="off"></label></section>
<section class="settings-section"><h3>Accessories</h3><div class="form-grid two"><label><span>Lights relay</span><select name="lights_relay"></select></label><label><span>Camera relay</span><select name="camera_relay"></select></label></div></section>
<p class="form-feedback" role="alert" hidden></p><div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save</button></div>
</form></dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20261002-spacing-motion-v2"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="controller-input.js?v=20261001-controller-v1"></script>
<script src="controller-input.js?v=20261022-v2"></script>
<script src="dashboard.js?v=20261022-v2"></script>
<script src="console-view.js?v=20261002-console-v2"></script>
<script src="app.js?v=20261017-engage-center-v2"></script>
<script src="app.js?v=20261022-v2"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
/* V2 uses the existing deck, type scale, and flat settings rows. */
:root { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
[hidden] { display:none !important; }
.workspace-nav { display:flex; align-items:center; gap:12px; margin:0 0 10px; min-height:32px; }
.view-switch { display:flex; gap:2px; padding:3px; background:#e1e1e1; border-radius:20px; }
.view-switch button,.quiet-button { border:0; background:transparent; color:#737575; border-radius:18px; padding:6px 14px; cursor:pointer; font-size:12px; }
.view-switch [aria-pressed=true] { background:#fafafa; color:#242929; box-shadow:0 1px 3px #0000000c; }
.workspace-nav .quiet-button { margin-left:auto; }
.version-mark { color:#9a9e9d; font-size:10px; letter-spacing:.08em; }
#controller-settings { display:block; }
.controller-modal { width:min(730px,calc(100vw - 28px)); max-height:calc(100dvh - 28px); overflow:auto; }
.binding-head,.binding-row { display:grid; grid-template-columns:80px minmax(100px,1fr) minmax(100px,1fr) 28px 54px; gap:8px; align-items:center; }
.binding-head { font-size:10px; color:#858b89; padding:8px 0; }
.binding-row { padding:9px 0; border-top:1px solid #dedfdd; }
.binding-row > span { font-size:12px; font-weight:500; }
.binding-row meter { display:block; width:50px; height:3px; margin-top:7px; accent-color:#6d9c8b; }
.binding-row select,.binding-row input { height:34px; min-height:34px; padding:0 8px; border-radius:8px; font-size:12px; min-width:0; }
.binding-row input { text-align:center; padding:0 2px; font-variant-numeric:tabular-nums; }
.binding-swap { background:transparent; border:0; height:32px; color:#6a7470; font-size:19px; cursor:pointer; border-radius:7px; }
.binding-swap:hover { background:#e0e5e1; }
.mapping-tuning { margin-top:14px; }
.mapping-actions { margin-top:16px; font-size:12px; }
.mapping-actions summary { padding:8px 0; cursor:pointer; color:#727b76; }
.form-grid.three { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.dashboard { display:flex; flex:1; flex-direction:column; min-height:0; gap:0; overflow:auto; }
.camera-stage { flex:1 1 220px; min-height:150px; position:relative; border-radius:14px; overflow:hidden; background:#18282d; color:#f2f6f5; isolation:isolate; }
.camera-stage video,.camera-stage img,.camera-stage canvas { display:block; width:100%; height:100%; position:absolute; inset:0; object-fit:contain; }
.camera-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:#8fa5aa; font-size:12px; }
.camera-empty svg { width:40px; fill:none; stroke:currentColor; stroke-width:1.4; opacity:.65; }
.camera-overlay { pointer-events:none; position:absolute; top:14px; left:16px; right:16px; display:flex; align-items:center; gap:10px; font-size:10px; text-shadow:0 1px 3px #000; color:#d7e5e4; }
#camera-badge { border:1px solid #a8c1c245; border-radius:10px; padding:3px 8px; background:#16282c55; }
#recording-badge { color:#ff9e93; font-weight:600; }
#stream-clock { margin-left:auto; font-variant-numeric:tabular-nums; }
.dashboard-actions { display:flex; align-items:center; gap:5px; padding:10px 0; flex-wrap:wrap; }
.dashboard-actions button { min-height:32px; padding:6px 12px; border-radius:18px; border:0; background:#e0e2df; color:#46524b; font-size:11px; cursor:pointer; white-space:nowrap; }
.dashboard-actions button[aria-pressed=true] { background:#d0ddd5; color:#345844; }
.dashboard-actions button:disabled { opacity:.4; cursor:default; }
.telemetry-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); row-gap:12px; border-top:1px solid #d9deda; padding:14px 0; }
.telemetry-strip > div { min-width:0; display:flex; flex-direction:column; gap:3px; }
.telemetry-strip span { font-size:10px; color:#7b8480; }
.telemetry-strip strong { font-weight:400; font-size:clamp(18px,2.2vw,26px); color:#36463d; font-variant-numeric:tabular-nums; letter-spacing:-.035em; white-space:nowrap; }
.telemetry-strip [data-stale=true] strong { color:#a4aaa5; }
.sensor-details { border-top:1px solid #d9deda; padding:10px 0 0; font-size:11px; }
.sensor-details summary { cursor:pointer; color:#58665d; }
.sensor-details summary span { float:right; color:#939c96; font-size:10px; }
.sensor-values { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:14px 0; }
.sensor-values div { min-width:0; display:flex; flex-direction:column; gap:4px; }
.sensor-values span { color:#87908a; font-size:10px; }
.sensor-values strong { font-size:11px; font-weight:500; color:#4b5c50; font-variant-numeric:tabular-nums; }
.detected-frame { display:flex; flex-direction:column; gap:9px; color:#859086; font-size:12px; padding-top:4px; }
.detected-frame strong { color:#4b5c50; font-weight:500; }
.extra-controls { display:flex; gap:16px; margin-top:8px; padding:10px 0 0; border-top:1px solid #d9deda; }
.extra-controls label { display:grid; flex:1; min-width:0; grid-template-columns:1fr 24px; gap:4px; color:#7e8981; font-size:10px; }
.extra-controls label input { width:100%; min-width:0; grid-row:2; grid-column:1/-1; accent-color:#668c78; height:18px; }
.extra-controls output { text-align:right; font-variant-numeric:tabular-nums; }
.primary-control-zone { grid-template-rows:clamp(64px,11vh,90px) minmax(0,1fr) auto auto; }
.controls-grid { --joy-size:min(34vh,calc(100cqw - 146px),370px); }
.workspace-topbar { margin-bottom:8px; }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):active { transform:scale(.97); }
:is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button):focus-visible { outline:2px solid #6b9a84; outline-offset:2px; }
.station-modal { width:min(560px,calc(100vw - 28px)); }
body[data-view=dashboard] #controller-status { position:absolute; clip:rect(0,0,0,0); }
@media (max-width:760px) {
.binding-head,.binding-row { grid-template-columns:66px minmax(0,1fr) minmax(0,1fr) 24px 46px; gap:6px; }
.binding-row select { padding:0 4px; font-size:11px; }
.controller-modal { padding:22px 18px; }
.controls-workspace { padding:16px; }
.dashboard-actions { gap:4px; }.dashboard-actions button { padding:6px 9px; }
}
@media (max-width:600px) {
.app-shell { grid-template-columns:170px minmax(0,1fr); }
.sidebar-deck { padding:14px; }
.input-mode-switch button { padding:6px 8px; }
.controls-grid { --joy-size:min(30vh,calc(100cqw - 95px),300px); grid-template-columns:minmax(0,1fr) 70px; gap:6px; }
.control-toolbar { flex-wrap:wrap; gap:8px; }.control-toolbar .range-pill { width:100px; }
.control-toolbar .output-limit > span { width:auto; flex-basis:auto; }
.window-actions { gap:0; }.window-actions button { width:24px; }
.extra-controls { gap:8px; }.workspace-nav { gap:6px; }
.view-switch button { padding:6px 9px; }.workspace-nav .quiet-button { padding:6px; }
.version-mark { display:none; }
}
@media (prefers-reduced-motion:reduce) { :is(.view-switch button,.quiet-button,.binding-swap,.dashboard-actions button) { transition:none; transform:none !important; } }