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