Add D455 sensor host and shared Node/Core preparation surface

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 22:27:07 +03:00
parent 3616acc648
commit b1aaa40508
41 changed files with 2293 additions and 19 deletions
+37
View File
@@ -0,0 +1,37 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './contracts';
import {PointViewport} from './PointViewport';
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
export function LiveViewport({device,layer,transport,failure}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void}){
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
useEffect(()=>{
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?'Прямой эфир':pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
async function connect(){try{
await pc.setLocalDescription(await pc.createOffer());
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{const t=setTimeout(()=>reject(new Error('Не удалось подготовить частный канал просмотра.')),10000);pc.addEventListener('icegatheringstatechange',()=>{if(pc.iceGatheringState==='complete'){clearTimeout(t);resolve();}});});
if(cancelled)return;
const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id;
if(cancelled){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);return;}
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,layer,transport]);
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
const active=device.snapshot.acquisition==='streaming';
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&state==='Прямой эфир'?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
<p className="sensor-note">{layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.'}</p>
</>}
</SettingsCard></div>;
}
+23
View File
@@ -0,0 +1,23 @@
import {useEffect,useRef} from 'react';
/** Camera optical coordinates, metres. A bounded live preview, never the raw cloud. */
export function PointViewport({points}:{points:number[]}) {
const ref=useRef<HTMLCanvasElement>(null);const orbit=useRef({yaw:0,pitch:0,scale:1});const data=useRef(points);data.current=points;
useEffect(()=>{
const canvas=ref.current!;const ctx=canvas.getContext('2d')!;let dragging=false;let x=0,y=0;
const draw=()=>{const box=canvas.getBoundingClientRect();canvas.width=box.width*devicePixelRatio;canvas.height=box.height*devicePixelRatio;ctx.clearRect(0,0,canvas.width,canvas.height);
const color=getComputedStyle(canvas).getPropertyValue('--nodedc-text-primary');ctx.fillStyle=color.trim()||'currentColor';
const a=orbit.current.yaw,b=orbit.current.pitch;const s=Math.min(canvas.width,canvas.height)*0.7*orbit.current.scale;
for(let i=0;i<data.current.length;i+=3){const [px,py,pz]=data.current.slice(i,i+3);const xx=px*Math.cos(a)+(pz-2)*Math.sin(a);const zz=-px*Math.sin(a)+(pz-2)*Math.cos(a);const yy=py*Math.cos(b)-zz*Math.sin(b);const z=py*Math.sin(b)+zz*Math.cos(b)+3;if(z<=0.05)continue;
ctx.globalAlpha=Math.min(1,Math.max(.2,1-pz/15));ctx.fillRect(canvas.width/2+xx/z*s,canvas.height/2+yy/z*s,2*devicePixelRatio,2*devicePixelRatio);
}ctx.globalAlpha=1;
};
const resize=new ResizeObserver(draw);resize.observe(canvas);const timer=setInterval(draw,250);
const down=(e:PointerEvent)=>{dragging=true;x=e.clientX;y=e.clientY;canvas.setPointerCapture(e.pointerId);};
const move=(e:PointerEvent)=>{if(!dragging)return;orbit.current.yaw+=(e.clientX-x)*.01;orbit.current.pitch+=(e.clientY-y)*.01;x=e.clientX;y=e.clientY;draw();};
const up=()=>{dragging=false;};const wheel=(e:WheelEvent)=>{e.preventDefault();orbit.current.scale=Math.max(.2,Math.min(5,orbit.current.scale*Math.exp(-e.deltaY*.001)));draw();};
canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointermove',move);canvas.addEventListener('pointerup',up);canvas.addEventListener('wheel',wheel,{passive:false});
draw();return()=>{resize.disconnect();clearInterval(timer);canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointermove',move);canvas.removeEventListener('pointerup',up);canvas.removeEventListener('wheel',wheel);};
},[]);
return <canvas ref={ref} className="sensor-media" aria-label="Облако точек камеры в метрах. Перетаскивание вращает, прокрутка меняет масштаб."/>;
}
+26
View File
@@ -0,0 +1,26 @@
import {useEffect,useState} from 'react';
import {ActivityIndicator,Button,Select,SettingsCard,TextField} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './contracts';
import {LiveViewport} from './LiveViewport';
export function SensorDetail({device,transport,back,refresh,failure}:{device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
const groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))];
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
const available=[...new Set((detail?.profiles??[]).map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting';
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>Остановить захват</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
</SettingsCard>
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={pending||active}/>)}</div></SettingsCard>
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min}${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only}/><Button disabled={pending||currentOption.read_only||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
</div>;
}
@@ -0,0 +1,24 @@
import {useCallback,useEffect,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import './sensors.css';
export function SensorWorkspace({transport,enabled=true}:{transport:SensorTransport;enabled?:boolean}){
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
const failure=useCallback((e:unknown)=>{setError(e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{setInventory(await transport.inventory());setFresh(true);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
const device=inventory?.items.find(v=>v.id===selected);
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
<div className="sensor-actions"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:inventory.items.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{inventory.items.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Нужно подготовить';
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{busy?<ActivityIndicator label={label}/>:item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
{(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>)}</SettingsCard>}
</>}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/></Window>
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
}
+40
View File
@@ -0,0 +1,40 @@
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
}
export interface SensorInventory {
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
}
export interface SensorCommand {
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
idempotency_key:string; parameters:Record<string,unknown>;
}
export interface SensorOperation {state:string;error?:string;result?:unknown}
export interface SensorTransport {
inventory: () => Promise<SensorInventory>;
submit: (value:SensorCommand) => Promise<SensorOperation>;
operation: (id:string) => Promise<SensorOperation>;
}
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={}):SensorCommand {
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
session:{session_id:device.snapshot.context.session_id,device_id:device.id},action_id:action,
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:60000)).toISOString(),parameters};
}
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={}):Promise<T>{
const request=command(device,action,parameters);let value=await transport.submit(request);
const deadline=Date.parse(request.deadline_at)+10000;
while (value.state==='running'||value.state==='queued') {
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
await new Promise(resolve=>setTimeout(resolve,1000));value=await transport.operation(request.operation_id);
}
if(value.state!=='complete') throw new Error(value.error||'Операция не завершена. Обновите состояние устройства.');
return value.result as T;
}
+10
View File
@@ -0,0 +1,10 @@
.sensor-workspace, .sensor-content {display:grid;gap:var(--nodedc-space-4);min-width:0}
.sensor-actions {display:flex;align-items:center;gap:var(--nodedc-space-3);flex-wrap:wrap}
.sensor-fields {display:grid;gap:var(--nodedc-space-4);grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sensor-workspace p,.sensor-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);line-height:1.5}
.sensor-facts {display:grid;gap:var(--nodedc-space-4);font-size:var(--nodedc-font-size-sm)}
.sensor-facts dd {margin:0}
.sensor-media {display:block;width:100%;height:360px;object-fit:contain;touch-action:none}
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:200;background:var(--nodedc-surface-1);overflow:auto}
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}