chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {bridgeFormValid,enroll,type EnrollmentState,type EnrollmentTransport} from './enrollment';
|
||||
|
||||
export function DeviceEnrollmentWindow({transport,onClose,onChange}:{transport:EnrollmentTransport;onClose:()=>void;onChange:()=>void}){
|
||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||
const [device,setDevice]=useState('');const [ssid,setSSID]=useState('');const [password,setPassword]=useState('');
|
||||
const [networks,setNetworks]=useState<NonNullable<EnrollmentState['networks']>>([]);
|
||||
const [network,setNetwork]=useState('manual');const [busy,setBusy]=useState('loading');
|
||||
const [error,setError]=useState('');const [notice,setNotice]=useState('');const [scanned,setScanned]=useState(false);
|
||||
const active=useRef(true);
|
||||
useEffect(()=>{active.current=true;void transport.state().then(value=>{if(active.current)setState(value);}).catch(()=>{if(active.current)setError('Не удалось получить состояние БК.');}).finally(()=>{if(active.current)setBusy('');});return()=>{active.current=false;};},[transport]);
|
||||
const ready=state?.available&&state.fresh!==false&&!!state.runtime_id;
|
||||
const selected=state?.candidates?.some(v=>v.id===device);
|
||||
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
||||
if(!state||busy)return;setBusy(action);setError('');setNotice('');
|
||||
const parameters=action==='connect'||action==='verify'?{device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,...(action==='connect'?{ssid,password}:{})}:{};
|
||||
if(action==='connect')setPassword('');
|
||||
try {
|
||||
const result=await enroll(transport,state,action,parameters);onChange();
|
||||
if(!active.current)return;
|
||||
setState({...result,node_id:state.node_id,name:state.name,fresh:true});
|
||||
if(action==='scan'){setDevice('');setScanned(true);}
|
||||
if(action==='networks')setNetworks(result.networks??[]);
|
||||
if(action==='connect'||action==='verify')setNotice(result.connected?'K1 подключён к БК.': 'Связь K1 с БК пока не подтверждена. Проверьте сеть и обновите состояние.');
|
||||
}catch(e){if(active.current)setError(e instanceof Error?e.message:'Не удалось выполнить действие K1.');}
|
||||
finally{if(active.current)setBusy('');}
|
||||
}
|
||||
const close=()=>{setPassword('');onClose();};
|
||||
return <Window open title="Подключение устройства к БК" onClose={close} footer={<WindowFooterActions>
|
||||
<Button onClick={close}>Закрыть</Button><Button variant="primary" disabled={!ready||!!busy||!selected||!bridgeFormValid(ssid,password)} onClick={()=>void run('connect')}>Подключить</Button>
|
||||
</WindowFooterActions>}><div className="sensor-content" aria-busy={!!busy}>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'} description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
|
||||
<ResourceRow title="XGRIDS K1 · Bridge" status={<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':busy==='loading'?'Получаем сведения':'Подключение недоступно'}</StatusBadge>}/>
|
||||
</SettingsCard>
|
||||
{busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:!ready?<SettingsCard title="Служба подключения устройств на БК недоступна" description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:<>
|
||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={<Button disabled={!!busy} onClick={()=>void run('scan')}>Найти K1</Button>}/>
|
||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден" description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||
{!!state?.candidates?.length&&<Select label="Устройство K1" value={device} options={[{value:'',label:'Выберите K1',disabled:true},...state.candidates.map(v=>({value:v.id,label:v.name}))]} onChange={setDevice} disabled={!!busy}/>}
|
||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите доступную сеть или введите её название. БК должен иметь доступ к этой сети." actions={<Button disabled={!!busy} onClick={()=>void run('networks')}>Найти сети</Button>}/>
|
||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network} options={[{value:'manual',label:'Ввести название сети'},...networks.map((v,i)=>({value:String(i),label:v.ssid,description:`Сигнал ${v.signal}% · ${v.security||'Без защиты'}`}))]} onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={!!busy}/>}
|
||||
<TextField label="Название сети Wi-Fi" value={ssid} onChange={e=>{setSSID(e.target.value);setNetwork('manual');}} disabled={!!busy} autoComplete="off"/>
|
||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={!!busy} autoComplete="new-password"/>
|
||||
<Button disabled={!!busy||!selected} onClick={()=>void run('verify')}>Проверить текущее подключение</Button>
|
||||
</>}
|
||||
{!!busy&&busy!=='loading'&&<ActivityIndicator label={busy==='scan'?'Ищем K1 на БК':busy==='networks'?'Ищем сети рядом с БК':'Проверяем подключение K1'}/>}
|
||||
{notice&&<SettingsCard title={notice}/>}{error&&<SettingsCard title={error}/>}
|
||||
</div></Window>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
import {K1LiveSettings} from './K1LiveSettings';
|
||||
|
||||
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(false),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
|
||||
const streaming=device.snapshot.acquisition==='streaming';
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||
async function act(action:'start'|'stop'|'verify'){
|
||||
if(busy)return;setBusy(true);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{setBusy(false);}
|
||||
}
|
||||
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={streaming&&enabled?'success':'neutral'}>{streaming?'Живой просмотр':'K1 · Bridge'}</StatusBadge><IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></div>
|
||||
<SettingsCard title={device.name} description="Камера и лидар работают на бортовом компьютере. Исходные данные записи сохраняются на БК.">
|
||||
<div className="sensor-actions"><Button disabled={!enabled||busy||streaming||!device.control?.can_start} onClick={()=>void act('start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!streaming&&!device.control?.can_stop)} onClick={()=>void act('stop')}>Остановить</Button><Button disabled={!enabled||busy} onClick={()=>{void refresh();setGeneration(v=>v+1);}}>Обновить просмотр</Button><IconButton label="Настройки живого просмотра" onClick={()=>setSettings(v=>!v)}><Icon name="settings"/></IconButton></div>
|
||||
</SettingsCard>
|
||||
{settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost}/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
|
||||
export interface LiveSettings {
|
||||
point_size:number; accumulation_seconds:number;
|
||||
color_mode:'intensity'|'height'|'distance'|'rgb'|'class';
|
||||
palette:'turbo'|'viridis'|'plasma'|'grayscale'|'custom'; custom_color:string;
|
||||
show_points:boolean;show_trajectory:boolean;show_grid:boolean;
|
||||
show_detections_2d:boolean;show_segmentation:boolean;show_cuboids_3d:boolean;
|
||||
}
|
||||
const defaults:LiveSettings={point_size:2.5,accumulation_seconds:12,color_mode:'intensity',palette:'turbo',custom_color:'#f7f8f4',show_points:true,show_trajectory:true,show_grid:true,show_detections_2d:false,show_segmentation:false,show_cuboids_3d:false};
|
||||
|
||||
export function K1LiveSettings({device,transport,enabled,refresh,failure}:{device:Sensor;transport:SensorTransport;enabled:boolean;refresh:()=>Promise<void>;failure:(error:unknown)=>void}){
|
||||
const [settings,setSettings]=useState<LiveSettings>({...defaults,...device.live_settings});
|
||||
const [busy,setBusy]=useState(false);
|
||||
const valid=Number.isFinite(settings.point_size)&&settings.point_size>=0.5&&settings.point_size<=12&&Number.isFinite(settings.accumulation_seconds)&&settings.accumulation_seconds>=0&&settings.accumulation_seconds<=120;
|
||||
async function apply(){setBusy(true);failure(null);try{await perform(transport,device,'option',{profile:'live-acquisition',settings});await refresh();}catch(error){failure(error);}finally{setBusy(false);}}
|
||||
return <SettingsCard title="Живой просмотр" description="Настройки текущего потока с БК. Воспроизведение записей и результаты LAB настраиваются отдельно.">
|
||||
<div className="sensor-fields">
|
||||
<TextField label="Размер точек" type="number" min={0.5} max={12} step={0.5} value={String(settings.point_size)} disabled={busy} onChange={event=>setSettings(v=>({...v,point_size:Number(event.target.value)}))}/>
|
||||
<TextField label="Накопление, с" type="number" min={0} max={120} step={1} value={String(settings.accumulation_seconds)} disabled={busy} onChange={event=>setSettings(v=>({...v,accumulation_seconds:Number(event.target.value)}))}/>
|
||||
<Select label="Цвет точек" value={settings.color_mode} disabled={busy} options={[{value:'intensity',label:'Интенсивность'},{value:'height',label:'Высота'},{value:'distance',label:'Расстояние'},{value:'rgb',label:'Цвет камеры'},{value:'class',label:'Класс'}]} onChange={value=>setSettings(v=>({...v,color_mode:value as LiveSettings['color_mode']}))}/>
|
||||
<Select label="Палитра" value={settings.palette} disabled={busy} options={[{value:'turbo',label:'Turbo'},{value:'viridis',label:'Viridis'},{value:'plasma',label:'Plasma'},{value:'grayscale',label:'Оттенки серого'}]} onChange={value=>setSettings(v=>({...v,palette:value as LiveSettings['palette']}))}/>
|
||||
</div>
|
||||
<div className="sensor-actions"><Switch label="Точки" checked={settings.show_points} disabled={busy} onChange={value=>setSettings(v=>({...v,show_points:value}))}/><Switch label="Траектория" checked={settings.show_trajectory} disabled={busy} onChange={value=>setSettings(v=>({...v,show_trajectory:value}))}/><Switch label="Сетка" checked={settings.show_grid} disabled={busy} onChange={value=>setSettings(v=>({...v,show_grid:value}))}/><Button disabled={!enabled||busy||!valid} onClick={()=>void apply()}>Применить</Button></div>
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
|
||||
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}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory}){
|
||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
||||
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"/>}<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>;
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,Settin
|
||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
import {sensorStatus} from './sensorStatus';
|
||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
import {K1Detail} from './K1Detail';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
import './sensors.css';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory}){
|
||||
const [adding,setAdding]=useState(false);
|
||||
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===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||
@@ -19,20 +23,21 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo
|
||||
},[transport,enabled,refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');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);
|
||||
const connected=inventory?.items.filter(v=>v.online)??[];
|
||||
const connected=inventory?.items.filter(v=>v.online||v.kind==='k1')??[];
|
||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
return <div className="sensor-workspace">{device?<SensorDetail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
return <div className="sensor-workspace">{device?device.kind==='k1'?<K1Detail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<SensorDetail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&<IconButton label="Подключить устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте его через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} 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>}
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={item.kind==='k1'?'network':'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} 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>}
|
||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&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>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&editing.kind!=='k1'&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
{adding&&transport.enrollment&&<DeviceEnrollmentWindow transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
kind?:'k1'; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null};
|
||||
live_settings?: import('./K1LiveSettings').LiveSettings;
|
||||
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
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[];
|
||||
@@ -18,6 +20,7 @@ export interface SensorCommand {
|
||||
}
|
||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||
export interface SensorTransport {
|
||||
enrollment?: import('./enrollment').EnrollmentTransport;
|
||||
inventory: () => Promise<SensorInventory>;
|
||||
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
|
||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||
@@ -27,7 +30,7 @@ export function command(device:Sensor,action:string,parameters:Record<string,unk
|
||||
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};
|
||||
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:device.kind==='k1'&&['start','verify','stop'].includes(action)?175000: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);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface EnrollmentState {
|
||||
available:boolean; fresh?:boolean; node_id:string; name?:string; runtime_id?:string;
|
||||
mode?:'bridge'; model?:string; discovery_generation?:number; mode_revision?:number;
|
||||
candidates?:{id:string;name:string;rssi?:number}[];
|
||||
networks?:{ssid:string;signal:number;security:string}[];
|
||||
connected?:boolean; ready_to_start?:boolean; selected_device_id?:string; ip?:string;
|
||||
device_session?:{device_session_id:string;device_id:string}; observed_at?:string;
|
||||
}
|
||||
export interface EnrollmentCommand {
|
||||
operation_id:string; node_id:string; runtime_id:string; action:'scan'|'networks'|'connect'|'verify';
|
||||
deadline_at:string; parameters:Record<string,unknown>;
|
||||
}
|
||||
export interface EnrollmentOperation {operation_id?:string;state:string;error?:string;result?:EnrollmentState}
|
||||
export interface EnrollmentTransport {
|
||||
state:()=>Promise<EnrollmentState>;
|
||||
submit:(command:EnrollmentCommand)=>Promise<EnrollmentOperation>;
|
||||
operation:(id:string)=>Promise<EnrollmentOperation>;
|
||||
}
|
||||
export function enrollmentCommand(state:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={}):EnrollmentCommand {
|
||||
if(!state.available||state.fresh===false||!state.runtime_id)throw new Error('БК или служба K1 недоступны. Обновите сведения.');
|
||||
return {operation_id:'op_'+crypto.randomUUID().replaceAll('-',''),node_id:state.node_id,runtime_id:state.runtime_id,action,
|
||||
deadline_at:new Date(Date.now()+170000).toISOString(),parameters};
|
||||
}
|
||||
export async function enroll(transport:EnrollmentTransport,state:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={}):Promise<EnrollmentState>{
|
||||
const command=enrollmentCommand(state,action,parameters);
|
||||
let operation:EnrollmentOperation;
|
||||
try {operation=await transport.submit(command);}finally{delete command.parameters.password;}
|
||||
while(operation.state==='running'||operation.state==='queued'){
|
||||
if(Date.now()>Date.parse(command.deadline_at)+10000)throw new Error('Ответ БК пока не получен. Обновите состояние K1.');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));operation=await transport.operation(command.operation_id);
|
||||
}
|
||||
if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||'Действие не подтверждено. Обновите состояние K1.');
|
||||
return operation.result;
|
||||
}
|
||||
export function bridgeFormValid(ssid:string,password:string):boolean{
|
||||
const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Native renderer capability supplied by each host, independent of device APIs. */
|
||||
export interface LiveRerunViewer {
|
||||
start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean})=>Promise<void>;
|
||||
open_channel:(name:string)=>{ready:boolean;send_rrd:(bytes:Uint8Array)=>void;close:()=>void};
|
||||
override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void;
|
||||
get_active_recording_id:()=>string|null;
|
||||
get_time_range:(id:string,timeline:string)=>{min:number;max:number}|null;
|
||||
set_active_timeline:(id:string,timeline:string)=>void;
|
||||
set_current_time:(id:string,timeline:string,time:number)=>void;
|
||||
set_playing:(id:string,playing:boolean)=>void;
|
||||
}
|
||||
export type RerunHostFactory=(element:HTMLElement)=>{ready:Promise<{viewer:LiveRerunViewer;mount:HTMLElement}>;dispose:()=>void};
|
||||
@@ -9,3 +9,7 @@
|
||||
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:var(--nodedc-layer-overlay);background:var(--nodedc-canvas);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)}
|
||||
.sensor-live-layout {display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--nodedc-space-4)}
|
||||
.sensor-live-spatial {min-height:420px;overflow:hidden}
|
||||
.sensor-live-spatial iframe {display:block;width:100%;height:100%;min-height:420px;border:0}
|
||||
.sensor-viewer-expanded .sensor-live-spatial,.sensor-viewer-expanded .sensor-live-spatial iframe {min-height:calc(100vh - 230px)}
|
||||
|
||||
Reference in New Issue
Block a user