feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
This commit is contained in:
@@ -6,7 +6,7 @@ export function RecoverySettings({value,online,enabled,pending,change}:{
|
||||
}) {
|
||||
const failed=['exhausted','unavailable','preview_failed'].includes(value.phase);
|
||||
const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры':
|
||||
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено';
|
||||
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстановление подключения':'Включено';
|
||||
return <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается."
|
||||
actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}>
|
||||
<p>На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.</p>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
|
||||
{available&&status.recording===1&&<p>Запись продолжится после закрытия просмотра.</p>}
|
||||
</SettingsCard>
|
||||
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применяем настройку':'Получаем настройки камеры'}>
|
||||
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применение настройки':'Получение настроек камеры'}>
|
||||
{settings?<CameraSettings value={settings} disabled={!available||busy||status.recording!==0||status.preview!==0} apply={(key,value)=>void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?<p>Настройки недоступны. Обновите состояние камеры.</p>:null}
|
||||
</LoadingRegion>
|
||||
<SettingsCard title="Файлы на камере" actions={<Button disabled={!available||busy} loading={pending==='files.refresh'} onClick={()=>void action('files.list',{offset:0},'files.refresh')}>Обновить список</Button>}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {useEffect,useRef,useState,type RefObject} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {SensorObservationProps} from '../../../../packages/sensor-ui/src/observation';
|
||||
import {useSensorPreview} from '../../../../packages/sensor-ui/src/useSensorPreview';
|
||||
import {useObservationStart} from '../../../../packages/sensor-ui/src/useObservationStart';
|
||||
import {startX4Observation} from './observationStart';
|
||||
import {cameraStatus} from './model';
|
||||
|
||||
function Lens({video,side,label,live}:{video:RefObject<HTMLVideoElement|null>;side:number;label:string;live:boolean}){
|
||||
const canvas=useRef<HTMLCanvasElement>(null);const [supported,setSupported]=useState(true);
|
||||
useEffect(()=>{
|
||||
const source=video.current,target=canvas.current;if(!source||!target)return;
|
||||
let handle:number|undefined,frame:number|undefined,active=true;
|
||||
const draw=()=>{
|
||||
if(!active)return;
|
||||
if(source.readyState>=2&&source.videoWidth){
|
||||
const valid=Math.abs(source.videoWidth/source.videoHeight-2)<.02;setSupported(valid);
|
||||
if(valid){const size=Math.max(1,Math.round(Math.min(target.clientWidth,target.clientHeight)*Math.min(devicePixelRatio,2)));if(target.width!==size||target.height!==size){target.width=size;target.height=size;}target.getContext('2d')?.drawImage(source,side*source.videoWidth/2,0,source.videoWidth/2,source.videoHeight,0,0,size,size);}
|
||||
}
|
||||
};
|
||||
const tick=()=>{draw();if(active)handle=source.requestVideoFrameCallback(tick);};
|
||||
const fallback=()=>{draw();if(active)frame=requestAnimationFrame(fallback);};
|
||||
const resize=new ResizeObserver(draw);resize.observe(target);
|
||||
if(typeof source.requestVideoFrameCallback==='function')handle=source.requestVideoFrameCallback(tick);else frame=requestAnimationFrame(fallback);
|
||||
return()=>{active=false;resize.disconnect();if(handle!==undefined)source.cancelVideoFrameCallback(handle);if(frame!==undefined)cancelAnimationFrame(frame);};
|
||||
},[video,side]);
|
||||
return <><canvas ref={canvas} aria-label={label} className="observation-lens" data-live={live&&supported}/>{!supported&&<p>Текущий формат изображения не содержит пару объективов.</p>}</>;
|
||||
}
|
||||
export function X4Observation({device,transport,enabled,views}:SensorObservationProps){
|
||||
const video=useRef<HTMLVideoElement>(null);const [generation,setGeneration]=useState(0);
|
||||
const status=cameraStatus(device);const available=enabled&&device.online&&status.connected;
|
||||
const activation=useObservationStart(device,available&&device.prepared,status.preview!==0,()=>startX4Observation(device,transport));
|
||||
const {state,error,live,hasFrame,sourceStalled}=useSensorPreview(device,'preview',transport,video,enabled,generation);
|
||||
const [attempts,setAttempts]=useState(0);
|
||||
const recovery=useObservationStart(device,available&&sourceStalled&&status.preview===1&&status.recording===0&&attempts<2,false,async()=>{setAttempts(v=>v+1);await startX4Observation(device,transport,true);setGeneration(v=>v+1);});
|
||||
useEffect(()=>{if(!live)return;const timer=setTimeout(()=>setAttempts(0),30000);return()=>clearTimeout(timer);},[live]);
|
||||
useEffect(()=>{setAttempts(0);},[device.id,device.snapshot.context.session_id]);
|
||||
const pending=activation.pending||recovery.pending;
|
||||
const failure=!live&&(activation.error||recovery.error||(sourceStalled&&attempts>=2&&!pending?'Не удалось восстановить изображение. Повторите просмотр.':''))||'';
|
||||
return <><video ref={video} autoPlay muted playsInline aria-hidden="true" className="observation-decoder"/>{views.map(view=>createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={pending?recovery.pending?'Восстановление изображения':'Запуск камеры':state}>{failure&&<IconButton label="Повторить просмотр X4" disabled={pending||!available} onClick={()=>{if(status.preview===0)void activation.retry();else{setAttempts(0);void recovery.retry();}}}><Icon name="refresh"/></IconButton>}{error&&!failure&&<IconButton label="Повторить подключение X4" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media"><Lens video={video} side={view.id==='left'?0:1} label={`${device.name} ${view.id==='left'?'Left':'Right'} Cam`} live={hasFrame}/></div>
|
||||
{!hasFrame&&<p className="observation-source__notice">{failure||error||(!available?'Нет свежей связи с камерой.':pending?'Подключение обоих объективов.':sourceStalled&&status.recording!==0?'Камера перестала передавать кадры. Восстановление доступно после завершения записи.':'Ожидание изображения.')}</p>}
|
||||
</div>,view.target,view.id))}</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {currentObservationDevice,observationOperation} from '../../../../packages/sensor-ui/src/observationStart';
|
||||
import {cameraStatus} from './model';
|
||||
|
||||
export function startX4Observation(device:Sensor,transport:SensorTransport,restart=false){
|
||||
return observationOperation(device,async()=>{
|
||||
let current=await currentObservationDevice(device,transport);let status=cameraStatus(current);
|
||||
if(!status.connected)throw new Error('Камера не подключена.');
|
||||
if(status.preview===1&&!restart)return;
|
||||
if(restart){
|
||||
if(status.recording!==0)throw new Error('Не удалось восстановить просмотр во время записи.');
|
||||
if(status.preview===1)await perform(transport,current,'preview.stop');
|
||||
current=await currentObservationDevice(device,transport);status=cameraStatus(current);
|
||||
if(status.recording!==0)throw new Error('Состояние записи изменилось. Повторите просмотр после её завершения.');
|
||||
}else if(status.preview!==0)throw new Error('Состояние просмотра пока не подтверждено.');
|
||||
await perform(transport,current,'preview.start');
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {X4Detail} from './X4Detail';
|
||||
import {cameraLabel,cameraRowActions} from './model';
|
||||
import {X4Observation} from './X4Observation';
|
||||
|
||||
export const insta360X4SensorUi:SensorUiContribution={
|
||||
kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true,
|
||||
supportsPreparation:true,supportsRenaming:true,status:cameraLabel,
|
||||
rowActions:cameraRowActions,
|
||||
observation:{Session:X4Observation,views:device=>[{id:'left',label:`${device.name} Left Cam`},{id:'right',label:`${device.name} Right Cam`}]},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {observationRerun} from './observationRerun';
|
||||
import {useCallback,useEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader,type SensorObservationProps} from '@mission-core/sensor-sdk';
|
||||
import {useK1Preview,pendingPreview,type PreviewStatus} from './useK1Preview';
|
||||
|
||||
function ActiveK1Observation({device,transport,enabled,views,createRerunHost}:SensorObservationProps){
|
||||
const spatial=useRef<HTMLDivElement>(null),plan=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [status,setStatus]=useState(pendingPreview),[generation,setGeneration]=useState(0);const attempts=useRef(0);
|
||||
const report=useCallback((value:PreviewStatus)=>setStatus(value),[]);
|
||||
const host=useMemo(()=>observationRerun(createRerunHost,plan),[createRerunHost]);
|
||||
useK1Preview(device,transport,host,spatial,video,report,generation,enabled);
|
||||
useEffect(()=>{if(!status.retry)return;const timer=setTimeout(()=>setGeneration(v=>v+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));return()=>clearTimeout(timer);},[status.retry]);
|
||||
return <>{views.map(view=>{const points=view.id!=='camera',live=points?status.presented:status.cameraPresented;return createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={live?'Прямой эфир':points?status.lidar:status.camera}>{status.retry&&<IconButton label="Повторить подключение K1" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media">{points?<div className="observation-rerun" ref={view.id==='plan'?plan:spatial}/>:<video ref={video} autoPlay muted playsInline aria-label={`${device.name} Cam Right`} data-live={live}/>}</div>
|
||||
</div>,view.target,view.id);})}</>;
|
||||
}
|
||||
export function K1Observation(props:SensorObservationProps){
|
||||
if(!props.enabled||props.device.snapshot.acquisition!=='streaming')return <>{props.views.map(view=>createPortal(<div className="observation-source"><ObservationHeader targets={view} live={false} label={props.enabled?'Просмотр остановлен':'Нет связи с источником'}/><p className="observation-source__notice">Начните приём данных в конфигураторе K1.</p></div>,view.target,view.id))}</>;
|
||||
return <ActiveK1Observation {...props}/>;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
import type {RefObject} from 'react';
|
||||
import type {RerunHostFactory,LiveRerunViewer} from '@mission-core/sensor-sdk';
|
||||
|
||||
/** One incoming RRD stream, two independently navigable native views. */
|
||||
export function observationRerun(factory:RerunHostFactory,plan:RefObject<HTMLDivElement|null>):RerunHostFactory {
|
||||
return element=>{
|
||||
const spatial=factory(element),overhead=factory(plan.current!);let disposed=false;
|
||||
const controller=new AbortController();
|
||||
const load=async(url:URL)=>{const response=await fetch(url,{signal:controller.signal});if(!response.ok)throw new Error('Не удалось загрузить вид облака точек.');return new Uint8Array(await response.arrayBuffer());};
|
||||
const ready=Promise.all([spatial.ready,overhead.ready,load(new URL('./observation-3d.rbl',import.meta.url)),load(new URL('./observation-plan.rbl',import.meta.url))]).then(([a,b,perspective,top])=>{
|
||||
let primaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,secondaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,recording:string|null=null;
|
||||
const viewer:LiveRerunViewer={
|
||||
...a.viewer,
|
||||
start:async(source,mount,options)=>{await a.viewer.start(source,mount,options);if(disposed)return;await b.viewer.start(source,b.mount,options);primaryBlueprint=a.viewer.open_channel('observation-perspective');secondaryBlueprint=b.viewer.open_channel('observation-plan');},
|
||||
open_channel:name=>{const first=a.viewer.open_channel(name),second=b.viewer.open_channel(name);return {get ready(){return first.ready&&second.ready;},send_rrd:bytes=>{second.send_rrd(bytes.slice());first.send_rrd(bytes);},close:()=>{first.close();second.close();}};},
|
||||
get_active_recording_id:()=>{const id=a.viewer.get_active_recording_id();if(id&&id!==recording&&primaryBlueprint?.ready&&secondaryBlueprint?.ready&&b.viewer.get_active_recording_id()===id){primaryBlueprint.send_rrd(perspective.slice());secondaryBlueprint.send_rrd(top.slice());recording=id;}return id;},
|
||||
override_panel_state:(panel,state)=>{a.viewer.override_panel_state(panel,state);b.viewer.override_panel_state(panel,state);},
|
||||
set_active_timeline:(id,timeline)=>{a.viewer.set_active_timeline(id,timeline);b.viewer.set_active_timeline(id,timeline);},
|
||||
};
|
||||
return {viewer,mount:a.mount};
|
||||
});
|
||||
return {ready,dispose:()=>{disposed=true;controller.abort();spatial.dispose();overhead.dispose();}};
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||
import {K1Detail} from './K1Detail';
|
||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
import {k1Status} from './presentation';
|
||||
import {K1Observation} from './K1Observation';
|
||||
|
||||
export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
supportsRenaming:false,status:k1Status,
|
||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||
observation:{Session:K1Observation,views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user