Preserve RRD preview frames and clarify onboard K1 status
This commit is contained in:
@@ -1,37 +1,41 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import {K1LiveSettings} from './K1LiveSettings';
|
||||
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
|
||||
import {pendingPreview} from './useK1Preview';
|
||||
|
||||
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
||||
const [busy,setBusy]=useState(''),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
|
||||
const [busy,setBusy]=useState(''),[settings,setSettings]=useState(false),[operationError,setOperationError]=useState('');
|
||||
const [preview,setPreview]=useState(pendingPreview);
|
||||
const running=useRef(false);
|
||||
const failedAction=useRef('');
|
||||
const streaming=device.snapshot.acquisition==='streaming';
|
||||
const manual=k1ManualState(device,enabled),status=k1Status(device,enabled);
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
|
||||
async function act(action:'start'|'stop'|'verify'){
|
||||
if(running.current||!enabled)return;
|
||||
running.current=true;setBusy(action);failure(null);
|
||||
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
|
||||
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
||||
catch(e){failure(e);}finally{running.current=false;setBusy('');}
|
||||
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});}
|
||||
finally{running.current=false;setBusy('');}
|
||||
}
|
||||
return <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={status.tone}>{status.label}</StatusBadge>{streaming&&<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}</div></div>
|
||||
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
|
||||
const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label);
|
||||
return <div className="sensor-content">
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
||||
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!busy} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||
{manual.connected&&!manual.showStop&&<Button variant="primary" disabled={!!busy||!manual.canStart} aria-busy={busy==='start'}
|
||||
icon={busy==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{busy==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||
{manual.showStop&&<Button disabled={!manual.connected||!!busy||(!streaming&&!device.control?.can_stop)} aria-busy={busy==='stop'}
|
||||
icon={busy==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{busy==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||
{!manual.connected&&<SettingsCard align="center" title={k1ConnectionNotice(device,enabled)}>
|
||||
{device.control?.can_verify&&<Button disabled={!enabled||!!busy} aria-busy={busy==='verify'} icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
|
||||
</SettingsCard>}
|
||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
|
||||
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
|
||||
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||
{!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
|
||||
</SettingsCard>
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{!streaming&&(busy==='start'||manual.active)&&<ActivityIndicator label="Запускаем K1 и ожидаем живые данные"/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost} onReconnect={()=>setGeneration(value=>value+1)}/>}
|
||||
<SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,26 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {Icon,IconButton,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import type {Sensor,SensorTransport} from './runtime';
|
||||
import {useK1Preview,type PreviewStatus} from './useK1Preview';
|
||||
import './livePreview.css';
|
||||
|
||||
function privateCandidate(sdp:string):string{
|
||||
return sdp.split('\r\n').filter(line=>{
|
||||
if(!line.startsWith('a=candidate:'))return true;
|
||||
const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number);
|
||||
return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&&
|
||||
(parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127))));
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
export function K1LiveView({device,transport,createRerunHost,onReconnect}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onReconnect:()=>void}){
|
||||
export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){
|
||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
||||
const [expanded,setExpanded]=useState(false);
|
||||
const [generation,setGeneration]=useState(0),[status,setStatus]=useState<PreviewStatus|null>(null);
|
||||
const attempts=useRef(0);
|
||||
const report=useCallback((value:PreviewStatus)=>{setStatus(value);onStatus(value);},[onStatus]);
|
||||
useK1Preview(device,transport,createRerunHost,spatial,video,report,generation);
|
||||
useEffect(()=>{
|
||||
let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined;
|
||||
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined;
|
||||
let closeChannel:(()=>void)|undefined;
|
||||
const host=createRerunHost(spatial.current!);
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true});
|
||||
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
|
||||
const fail=(message:string)=>{failed=true;if(active){setError(message);setReady(false);}clearInterval(follow);clearInterval(keepalive);pc.close();};
|
||||
const cameraFail=(message:string)=>{if(active)setCameraError(message);camera.close();};
|
||||
rrd.onclose=()=>{if(active&&!failed)fail('Поток лидара завершён. Обновите состояние устройства.');};
|
||||
camera.onclose=()=>{if(active)setCameraError('Поток камеры недоступен. Обновите просмотр.');};
|
||||
const run=async()=>{
|
||||
const {viewer,mount}=await host.ready;if(!active)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return;
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);closeChannel=()=>channel.close();
|
||||
rrd.onmessage=event=>{if(!active||!(event.data instanceof ArrayBuffer))return;try{channel.send_rrd(new Uint8Array(event.data));}catch{fail('Поток лидара прерван. Обновите просмотр.');}};
|
||||
follow=setInterval(()=>{if(!active)return;try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){setReady(true);viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);}}catch{/* Viewer may still be opening its recording. */}},250);
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{const timeout=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),8000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(timeout);resolve();}};});
|
||||
if(!active)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer';camera_mime?:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
|
||||
peerID=answer.peer_id;
|
||||
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;}
|
||||
if(answer.camera_mime&&typeof MediaSource!=='undefined'&&MediaSource.isTypeSupported(answer.camera_mime)){
|
||||
const media=new MediaSource();mediaURL=URL.createObjectURL(media);video.current!.src=mediaURL;
|
||||
const pending:ArrayBuffer[]=[];let pendingBytes=0,buffer:SourceBuffer|undefined;
|
||||
const append=()=>{if(!active||!buffer||buffer.updating||media.readyState!=='open')return;try{
|
||||
const current=video.current?.currentTime??0;
|
||||
if(buffer.buffered.length&¤t-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),current-5);return;}
|
||||
const bytes=pending.shift();if(bytes){pendingBytes-=bytes.byteLength;buffer.appendBuffer(bytes);}
|
||||
}catch{cameraFail('Поток камеры прерван. Обновите просмотр.');}};
|
||||
media.addEventListener('sourceopen',()=>{if(!active)return;buffer=media.addSourceBuffer(answer.camera_mime!);buffer.addEventListener('updateend',()=>{const element=video.current;if(element&&buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-element.currentTime>1)element.currentTime=Math.max(0,end-0.2);void element.play().catch(()=>{});}append();});append();},{once:true});
|
||||
camera.onmessage=event=>{if(!(event.data instanceof ArrayBuffer)||!active)return;pendingBytes+=event.data.byteLength;if(pendingBytes>8*1024*1024){cameraFail('Просмотр камеры не успевает за потоком. Обновите просмотр.');return;}pending.push(event.data);append();};
|
||||
}
|
||||
else cameraFail('Камера пока недоступна в этом просмотре.');
|
||||
pc.onconnectionstatechange=()=>{if(active&&['failed','disconnected','closed'].includes(pc.connectionState))fail('Связь просмотра прервана. Обновите просмотр.');};
|
||||
await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});
|
||||
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
|
||||
};
|
||||
void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.'));
|
||||
return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);};
|
||||
},[device.snapshot.context.session_id,transport,createRerunHost]);
|
||||
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}{(error||cameraError)&&<Button onClick={onReconnect}>Восстановить просмотр</Button>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
|
||||
if(!status?.retry)return;
|
||||
const timer=setTimeout(()=>setGeneration(value=>value+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));
|
||||
return()=>clearTimeout(timer);
|
||||
},[status?.retry]);
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||
return <SettingsCard title="Rerun" className={expanded?'k1-preview sensor-viewer-expanded':'k1-preview'}
|
||||
actions={<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}>
|
||||
<div className="k1-preview-spatial" ref={spatial}/>
|
||||
<video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
</SettingsCard>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.k1-preview {min-width:0;width:100%}
|
||||
.k1-preview-spatial {position:relative;min-width:0;width:100%;height:clamp(420px,60vh,800px);overflow:hidden}
|
||||
.k1-preview-spatial iframe {display:block;position:absolute;inset:0;width:100%;height:100%;border:0}
|
||||
.k1-preview-camera {display:block;width:100%;height:clamp(220px,32vh,360px);object-fit:contain}
|
||||
.k1-preview-camera[hidden] {display:none}
|
||||
.k1-preview.sensor-viewer-expanded .k1-preview-spatial {height:calc(100vh - 150px)}
|
||||
@@ -2,6 +2,7 @@ import type {Sensor} from './runtime';
|
||||
|
||||
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
||||
if(device.control?.phase==='completed'&&device.snapshot.acquisition==='idle')return {label:'Устройство остановлено',tone:'neutral'};
|
||||
if(!device.online||!device.verified)return device.control?.network_applied
|
||||
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
|
||||
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
|
||||
@@ -13,6 +14,7 @@ export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutra
|
||||
|
||||
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
|
||||
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
|
||||
if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.';
|
||||
if(device.control?.reason_code==='application_authority_unavailable')
|
||||
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
||||
return device.control?.network_applied
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** One MSE decoder per preview; the onboard producer owns camera activation. */
|
||||
export function previewCamera(video:HTMLVideoElement,ready:()=>void,failed:()=>void){
|
||||
let active=true,media:MediaSource|undefined,buffer:SourceBuffer|undefined,url:string|undefined;
|
||||
let pending:Uint8Array<ArrayBuffer>[]=[],bytes=0;
|
||||
const append=()=>{
|
||||
if(!active||!buffer||buffer.updating||media?.readyState!=='open')return;
|
||||
try{
|
||||
if(buffer.buffered.length&&video.currentTime-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),video.currentTime-5);return;}
|
||||
const next=pending.shift();if(next){bytes-=next.byteLength;buffer.appendBuffer(next);}
|
||||
}catch{failed();}
|
||||
};
|
||||
const decoded=()=>{if(active)ready();};
|
||||
video.addEventListener('loadeddata',decoded);
|
||||
return {
|
||||
open(mime:string){
|
||||
if(media||typeof MediaSource==='undefined'||!MediaSource.isTypeSupported(mime))throw new Error('Camera format unavailable');
|
||||
media=new MediaSource();url=URL.createObjectURL(media);video.src=url;
|
||||
media.addEventListener('sourceopen',()=>{
|
||||
if(!active)return;
|
||||
try{
|
||||
buffer=media!.addSourceBuffer(mime);
|
||||
buffer.addEventListener('error',failed);
|
||||
buffer.addEventListener('updateend',()=>{
|
||||
if(!active)return;
|
||||
if(buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-video.currentTime>1)video.currentTime=Math.max(0,end-0.2);void video.play().catch(()=>{});}
|
||||
append();
|
||||
});append();
|
||||
}catch{failed();}
|
||||
},{once:true});
|
||||
},
|
||||
push(payload:Uint8Array<ArrayBuffer>){
|
||||
if(!media)throw new Error('Camera metadata missing');
|
||||
bytes+=payload.byteLength;if(bytes>8*1024*1024)throw new Error('Camera preview backlog');
|
||||
pending.push(payload);append();
|
||||
},
|
||||
close(){active=false;pending=[];bytes=0;video.removeEventListener('loadeddata',decoded);video.pause();video.removeAttribute('src');video.load();if(url)URL.revokeObjectURL(url);},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v1';
|
||||
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
|
||||
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
|
||||
return {
|
||||
push(data:ArrayBuffer) {
|
||||
const bytes=new Uint8Array(data);
|
||||
if(!pending){
|
||||
if(bytes.length!==8||bytes[0]!==77||bytes[1]!==67||bytes[2]!==70||bytes[3]!==49)throw new Error('Invalid preview envelope');
|
||||
const length=new DataView(data).getUint32(4);
|
||||
if(length<1||length>MAX_PAYLOAD)throw new Error('Preview exceeds bound');
|
||||
pending=new Uint8Array(length);offset=0;return;
|
||||
}
|
||||
if(!bytes.length||bytes.length>FRAGMENT_BYTES||offset+bytes.length>pending.length)throw new Error('Invalid preview fragment');
|
||||
pending.set(bytes,offset);offset+=bytes.length;
|
||||
if(offset===pending.length){const complete=pending;pending=null;offset=0;accept(complete);}
|
||||
},
|
||||
close(){pending=null;offset=0;},
|
||||
};
|
||||
}
|
||||
|
||||
export function assertRrd(bytes:Uint8Array){
|
||||
if(bytes.length<12||bytes[0]!==82||bytes[1]!==82||bytes[2]!==70||bytes[3]!==50)throw new Error('Invalid RRD recording');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {useEffect,type RefObject} from 'react';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {assertRrd,MEDIA_PROTOCOL,previewFrames} from './previewFrames';
|
||||
import {previewCamera} from './previewCamera';
|
||||
|
||||
export type PreviewStatus={lidar:string;camera:string;retry:boolean};
|
||||
export const pendingPreview:PreviewStatus={lidar:'Лидар: ожидаем данные',camera:'Камера: ожидаем изображение',retry:false};
|
||||
|
||||
function privateCandidate(sdp:string):string{
|
||||
return sdp.split('\r\n').filter(line=>{
|
||||
if(!line.startsWith('a=candidate:'))return true;
|
||||
const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number);
|
||||
return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&&
|
||||
(parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127))));
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
export function useK1Preview(device:Sensor,transport:SensorTransport,createRerunHost:RerunHostFactory,
|
||||
spatial:RefObject<HTMLDivElement|null>,video:RefObject<HTMLVideoElement|null>,onStatus:(value:PreviewStatus)=>void,generation=0){
|
||||
useEffect(()=>{
|
||||
let active=true,failed=false,peerID:string|undefined,viewClose:(()=>void)|undefined;
|
||||
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined,iceTimeout:ReturnType<typeof setTimeout>|undefined;
|
||||
let status={...pendingPreview};onStatus(status);
|
||||
const update=(patch:Partial<PreviewStatus>)=>{if(active){status={...status,...patch};onStatus(status);}};
|
||||
const host=createRerunHost(spatial.current!);
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true});
|
||||
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
|
||||
const fail=()=>{if(!active||failed)return;failed=true;update({lidar:'Лидар: восстанавливаем просмотр',camera:'Камера: ожидаем соединение',retry:true});clearInterval(follow);clearInterval(keepalive);pc.close();};
|
||||
const cameraFail=()=>{update({camera:'Камера: изображение недоступно'});camera.close();};
|
||||
const decoder=previewCamera(video.current!,()=>update({camera:'Камера: изображение поступает'}),cameraFail);
|
||||
const cameraFrames=previewFrames(payload=>decoder.push(payload));
|
||||
let rrdFrames:ReturnType<typeof previewFrames>|undefined;
|
||||
rrd.onclose=()=>{if(active&&!failed)fail();};
|
||||
camera.onclose=()=>{if(active&&!failed)update({camera:'Камера: изображение недоступно'});};
|
||||
camera.onmessage=event=>{
|
||||
if(!active)return;
|
||||
try{
|
||||
if(typeof event.data==='string'){
|
||||
const metadata=JSON.parse(event.data);
|
||||
if(metadata.type!=='camera-ready'||typeof metadata.mime!=='string')throw new Error('Invalid camera metadata');
|
||||
decoder.open(metadata.mime);
|
||||
}else if(event.data instanceof ArrayBuffer)cameraFrames.push(event.data);
|
||||
}catch{cameraFail();}
|
||||
};
|
||||
const run=async()=>{
|
||||
const {viewer,mount}=await host.ready;if(!active)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return;
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);viewClose=()=>channel.close();
|
||||
rrdFrames=previewFrames(payload=>{assertRrd(payload);if(!channel.ready)throw new Error('Viewer unavailable');channel.send_rrd(payload);});
|
||||
rrd.onmessage=event=>{if(active&&event.data instanceof ArrayBuffer)try{rrdFrames!.push(event.data);}catch{fail();}};
|
||||
follow=setInterval(()=>{
|
||||
if(!active||failed)return;
|
||||
try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){
|
||||
if(status.lidar!=='Лидар: данные поступают')update({lidar:'Лидар: данные поступают'});
|
||||
viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);
|
||||
}}catch{/* Native recording has not opened yet. */}
|
||||
},250);
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{
|
||||
iceTimeout=setTimeout(()=>reject(new Error('ICE timeout')),8000);
|
||||
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimeout);resolve();}};
|
||||
});
|
||||
if(!active)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer';media_protocol:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
|
||||
peerID=answer.peer_id;
|
||||
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;}
|
||||
if(answer.media_protocol!==MEDIA_PROTOCOL){failed=true;clearInterval(follow);pc.close();update({lidar:'Для просмотра требуется обновление приложения на БК',camera:'Камера: ожидаем обновление',retry:false});return;}
|
||||
pc.onconnectionstatechange=()=>{if(active&&['failed','closed'].includes(pc.connectionState))fail();};
|
||||
await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});
|
||||
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
|
||||
};
|
||||
void run().catch(fail);
|
||||
return()=>{
|
||||
active=false;clearInterval(keepalive);clearInterval(follow);clearTimeout(iceTimeout);
|
||||
rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.onicegatheringstatechange=null;pc.close();
|
||||
rrdFrames?.close();cameraFrames.close();decoder.close();try{viewClose?.();}finally{host.dispose();}
|
||||
if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});
|
||||
};
|
||||
},[device.snapshot.context.session_id,transport,createRerunHost,onStatus,spatial,video,generation]);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
||||
from debian import package # noqa: E402
|
||||
from runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.6"
|
||||
VERSION = "0.1.7"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
|
||||
@@ -16,6 +16,7 @@ RuntimeDirectoryMode=0750
|
||||
LoadCredentialEncrypted=k1-application
|
||||
UMask=0007
|
||||
Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2
|
||||
Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
|
||||
Reference in New Issue
Block a user