Files
argo/SUB Control/web/dashboard.js
T
PitresAli 8ccd7689f7 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.
2026-10-22 20:22:05 +02:00

230 lines
20 KiB
JavaScript

/* 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)}};
})();