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`}]},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user