Share the K1 live scene template and preserve idle media channels
This commit is contained in:
@@ -3,13 +3,15 @@ import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from
|
||||
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 {K1SceneWindows,type SceneTool} from './K1SceneWindows';
|
||||
import {useK1SceneSettings} from './useK1SceneSettings';
|
||||
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(''),[settings,setSettings]=useState(false),[operationError,setOperationError]=useState('');
|
||||
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
|
||||
const [preview,setPreview]=useState(pendingPreview);
|
||||
const scene=useK1SceneSettings(device,transport,failure);
|
||||
const running=useRef(false);
|
||||
const failedAction=useRef('');
|
||||
const streaming=device.snapshot.acquisition==='streaming';
|
||||
@@ -27,15 +29,15 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
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={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><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>
|
||||
<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}/>}
|
||||
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
|
||||
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -1,26 +1,69 @@
|
||||
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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
|
||||
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
||||
import type { Sensor, SensorTransport } from './runtime';
|
||||
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
||||
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
||||
import './livePreview.css';
|
||||
|
||||
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 [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(()=>{
|
||||
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"/>
|
||||
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
|
||||
device: Sensor;
|
||||
transport: SensorTransport;
|
||||
createRerunHost: RerunHostFactory;
|
||||
onStatus: (value: PreviewStatus) => void;
|
||||
enabled: boolean;
|
||||
scene: K1SceneState;
|
||||
openTool: (tool: SceneTool) => void;
|
||||
}) {
|
||||
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
|
||||
const [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
|
||||
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
|
||||
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
|
||||
const attempts = useRef(0);
|
||||
const report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
|
||||
useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
|
||||
useEffect(() => {
|
||||
if (!status.retry || !enabled)
|
||||
return;
|
||||
const timer = setTimeout(() => setGeneration(value => value + 1), Math.min(30000, 2000 * 2 ** Math.min(attempts.current++, 4)));
|
||||
return () => clearTimeout(timer);
|
||||
}, [status.retry, enabled]);
|
||||
useEffect(() => { if (!status.presented)
|
||||
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
setCameraMaximized(false);
|
||||
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
||||
const sources: SpatialSourceDescriptor[] = [
|
||||
{ id: 'lidar', label: 'Облако точек', modality: 'point-cloud', availability: status.presented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер' },
|
||||
{ id: 'camera', label: 'Камера K1', modality: 'video', availability: status.cameraPresented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер', delivery: true },
|
||||
];
|
||||
const toggle = (id: string) => setVisible(current => { const next = new Set(current); if (next.has(id))
|
||||
next.delete(id);
|
||||
else
|
||||
next.add(id); return next; });
|
||||
return <SettingsCard title="Пространственная модель" 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="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions"><SpatialToolbarActions openSource={() => openTool('source')} openLayers={() => openTool('layers')} openDisplay={() => openTool('display')}/></div></div>
|
||||
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
|
||||
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
|
||||
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
|
||||
</div>
|
||||
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
|
||||
<div className="scene-status scene-status--top-left" aria-hidden={cameraMaximized}>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span><StatusBadge tone={status.presented ? 'success' : 'neutral'}>{status.presented ? 'Визуализатор готов' : status.retry ? 'Восстановление связи' : 'Ожидаем свежие данные'}</StatusBadge>
|
||||
</div>
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
|
||||
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
|
||||
<div><span>До публикации</span><strong>{status.presented && device.frames?.mqtt_to_publish_ms != null ? device.frames.mqtt_to_publish_ms.toFixed(1) : '—'}<small> мс</small></strong></div>
|
||||
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
|
||||
</div>
|
||||
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||
{!cameraMaximized && <ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds => scene.stage({ accumulationSeconds })} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
||||
<FloatingMediaWindow title="Камера K1" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={() => { }} onClose={() => { setCameraMaximized(false); setVisible(current => { const next = new Set(current); next.delete('camera'); return next; }); }} status={<span className="floating-observation-window__status">{status.cameraPresented ? 'Эфир' : 'Ожидание'}</span>} footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
|
||||
<video className="observation-media__asset" style={{ visibility: status.cameraPresented ? 'visible' : 'hidden' }} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
{!status.cameraPresented && <div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
|
||||
</FloatingMediaWindow>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ControlRow, Inspector, Window } from '@nodedc/ui-react';
|
||||
import { SceneDisplayControls, SceneLayerControls } from '@mission-core/sensor-sdk';
|
||||
import type { useK1SceneSettings } from './useK1SceneSettings';
|
||||
export type SceneTool = 'source' | 'layers' | 'display';
|
||||
export type K1SceneState = ReturnType<typeof useK1SceneSettings>;
|
||||
export function K1SceneWindows({ tool, close, scene, connected }: {
|
||||
tool: SceneTool | null;
|
||||
close: () => void;
|
||||
scene: K1SceneState;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const title = tool === 'source' ? 'Визуальный движок' : tool === 'layers' ? 'Слои сцены' : 'Отображение';
|
||||
return <Window open={tool !== null} title={title} placement="end" draggable closeOnBackdrop={false} closeOnEscape={false} lockBodyScroll={false} trapFocus={false} className={`scene-tool-window scene-tool-window--${tool === 'source' ? 'source' : tool === 'layers' ? 'layers' : 'display'}`} onClose={() => { scene.flush(); close(); }}>
|
||||
{tool === 'display' && <SceneDisplayControls displayDraft={scene.draft} stageDisplayPatch={scene.stage} commitDisplayPatch={scene.commit} flushDisplaySettings={scene.flush}/>}
|
||||
{tool === 'layers' && <SceneLayerControls sceneSettings={scene.draft} pending={scene.pending} applyScenePatch={scene.commit}/>}
|
||||
{tool === 'source' && <Inspector defaultOpen={['connection']} singleOpen sections={[{ id: 'connection', label: 'Подключение', description: 'Живые данные с бортового компьютера', content: <div className="inspector-control-stack">
|
||||
<ControlRow label="Состояние">{connected ? 'Бортовой компьютер доступен' : 'Нет связи с бортовым компьютером'}</ControlRow>
|
||||
<ControlRow label="Источник">Камера и лидар K1</ControlRow>
|
||||
<ControlRow label="Режим">Живой просмотр</ControlRow>
|
||||
<p className="scene-window-note">Потоки открываются автоматически после запуска устройства. Запись сохраняется на бортовом компьютере.</p>
|
||||
</div> }]}/>}
|
||||
</Window>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Host allocation only; spatial controls and media geometry come from spatial-ui. */
|
||||
.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)}
|
||||
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
|
||||
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)}
|
||||
.k1-camera-notice {position:absolute;inset:0}
|
||||
.k1-preview-status {margin:0;text-align:center;color:var(--nodedc-text-muted);font-size:var(--nodedc-font-size-xs)}
|
||||
|
||||
@@ -1,38 +1,71 @@
|
||||
/** 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);},
|
||||
};
|
||||
export function previewCamera(video: HTMLVideoElement, ready: () => void, failed: () => void) {
|
||||
let active = true, media: MediaSource | undefined, buffer: SourceBuffer | undefined, url: string | undefined;
|
||||
const fail = () => { if (active) failed(); };
|
||||
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 {
|
||||
fail();
|
||||
}
|
||||
};
|
||||
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);
|
||||
media.addEventListener('sourceopen', () => {
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
buffer = media!.addSourceBuffer(mime);
|
||||
buffer.addEventListener('error', fail);
|
||||
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 {
|
||||
fail();
|
||||
}
|
||||
}, { once: true });
|
||||
// Installing the listener first also handles immediate sourceopen on reuse.
|
||||
video.src = url;
|
||||
},
|
||||
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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v1';
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Source-age gate, independent of wall-clock skew between operator and board. */
|
||||
export function previewFreshness(now: () => number = () => performance.now()) {
|
||||
let sequence = 0, expires = 0, points = 0;
|
||||
return {
|
||||
receive(value: unknown) {
|
||||
const frame = value as {
|
||||
type?: string;
|
||||
sequence?: number;
|
||||
age_ms?: number | null;
|
||||
points?: number;
|
||||
};
|
||||
if (frame?.type !== 'lidar-state' || !Number.isSafeInteger(frame.sequence) || frame.sequence! < 0 ||
|
||||
!Number.isSafeInteger(frame.points) || frame.points! < 0 ||
|
||||
(frame.age_ms !== null && (!Number.isFinite(frame.age_ms) || frame.age_ms! < 0)))
|
||||
throw new Error('Invalid live frame state');
|
||||
if (frame.sequence! <= sequence)
|
||||
return;
|
||||
sequence = frame.sequence!;
|
||||
points = frame.points!;
|
||||
expires = frame.age_ms === null ? 0 : now() + Math.max(0, 2000 - frame.age_ms!);
|
||||
},
|
||||
get fresh() { return sequence > 0 && now() < expires; },
|
||||
get points() { return points; },
|
||||
};
|
||||
}
|
||||
@@ -1,83 +1,198 @@
|
||||
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');
|
||||
import { useEffect, useState, type RefObject } from 'react';
|
||||
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
|
||||
import { previewCamera } from './previewCamera';
|
||||
import { previewFreshness } from './previewFreshness';
|
||||
export type PreviewStatus = {
|
||||
lidar: string;
|
||||
camera: string;
|
||||
retry: boolean;
|
||||
presented: boolean;
|
||||
cameraPresented: boolean;
|
||||
points: number;
|
||||
};
|
||||
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
|
||||
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]);
|
||||
export function useK1Preview(device: Sensor, transport: SensorTransport, createRerunHost: RerunHostFactory, spatial: RefObject<HTMLDivElement | null>, video: RefObject<HTMLVideoElement | null>, onStatus: (value: PreviewStatus) => void, generation = 0, enabled = true) {
|
||||
const [native, setNative] = useState<LiveRerunViewer | null>(null);
|
||||
// The native runtime belongs to the view. Media recovery opens a new recording
|
||||
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const host = createRerunHost(spatial.current!);
|
||||
void host.ready.then(async ({ viewer, mount }) => {
|
||||
if (!active)
|
||||
return;
|
||||
await viewer.start(null, mount, { width: '100%', height: '100%', theme: 'dark', hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||
panel_state_overrides: { top: 'Hidden', blueprint: 'Hidden', selection: 'Hidden', time: 'Hidden' } });
|
||||
if (!active)
|
||||
return;
|
||||
for (const panel of ['top', 'blueprint', 'selection', 'time'] as const)
|
||||
viewer.override_panel_state(panel, 'hidden');
|
||||
setNative(viewer);
|
||||
}).catch(() => { if (active)
|
||||
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
||||
return () => { active = false; setNative(null); host.dispose(); };
|
||||
}, [createRerunHost, spatial, onStatus]);
|
||||
useEffect(() => {
|
||||
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
|
||||
if (!native || !enabled)
|
||||
return;
|
||||
let active = true, failed = false, peerID: string | undefined;
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined, follow: ReturnType<typeof setInterval> | undefined, iceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let status = { ...pendingPreview };
|
||||
const update = (patch: Partial<PreviewStatus>) => { if (active) {
|
||||
const next = { ...status, ...patch };
|
||||
if (JSON.stringify(next) !== JSON.stringify(status)) {
|
||||
status = next;
|
||||
onStatus(status);
|
||||
}
|
||||
} };
|
||||
const viewer = native, freshness = previewFreshness();
|
||||
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 previousRecording = viewer.get_active_recording_id();
|
||||
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
|
||||
const fail = () => { if (!active || failed)
|
||||
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
|
||||
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
|
||||
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
|
||||
const decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
|
||||
const cameraFrames = previewFrames(payload => decoder.push(payload));
|
||||
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
|
||||
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
|
||||
rrd.onclose = () => { if (active && !failed)
|
||||
fail(); };
|
||||
camera.onclose = () => { if (active && !failed)
|
||||
cameraFail(); };
|
||||
rrd.onmessage = event => {
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
if (typeof event.data === 'string')
|
||||
freshness.receive(JSON.parse(event.data));
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
rrdFrames.push(event.data);
|
||||
}
|
||||
catch {
|
||||
fail();
|
||||
}
|
||||
};
|
||||
camera.onmessage = event => {
|
||||
if (!active || failed)
|
||||
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 () => {
|
||||
let synchronizedRecording: string | null = null;
|
||||
follow = setInterval(() => {
|
||||
if (!active || failed)
|
||||
return;
|
||||
let presented = false;
|
||||
try {
|
||||
const id = viewer.get_active_recording_id();
|
||||
const range = id ? viewer.get_time_range(id, 'stream_time') : null;
|
||||
if (id && range) {
|
||||
if (synchronizedRecording !== id) {
|
||||
viewer.set_active_timeline(id, 'stream_time');
|
||||
if (viewer.get_active_timeline(id) === 'stream_time')
|
||||
synchronizedRecording = id;
|
||||
}
|
||||
// Following is owned by the live blueprint. SetTime/SetPlaying would
|
||||
// pin the cursor and override that profile on every heartbeat.
|
||||
presented = id !== previousRecording && freshness.fresh && range.max > range.min;
|
||||
}
|
||||
}
|
||||
catch { /* Native recording/timeline creation is asynchronous. */ }
|
||||
seenLidar ||= presented;
|
||||
const v = video.current!;
|
||||
if (cameraDecoded && v.currentTime !== lastVideoTime) {
|
||||
lastVideoTime = v.currentTime;
|
||||
lastVideoProgress = performance.now();
|
||||
}
|
||||
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
|
||||
update({ presented, cameraPresented, points: presented ? freshness.points : 0,
|
||||
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||
}, 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), acquisition_id: device.control?.acquisition_id });
|
||||
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 c of [rrd, camera])
|
||||
if (c.readyState === 'open')
|
||||
c.send('keepalive'); }, 5000);
|
||||
};
|
||||
void run().catch(fail);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(keepalive);
|
||||
clearInterval(follow);
|
||||
clearTimeout(iceTimeout);
|
||||
rrd.onmessage = null;
|
||||
camera.onmessage = null;
|
||||
camera.onclose = null;
|
||||
rrd.onclose = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.close();
|
||||
rrdFrames.close();
|
||||
cameraFrames.close();
|
||||
decoder.close();
|
||||
channel.close();
|
||||
if (peerID)
|
||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||
};
|
||||
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { defaultSceneSettings, type SceneSettings } from '@mission-core/sensor-sdk';
|
||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||
const fields = { pointSize: 'point_size', accumulationSeconds: 'accumulation_seconds', colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color', showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid' } as const;
|
||||
export function k1SceneSettings(value: Record<string, unknown> = {}): SceneSettings {
|
||||
const settings = { ...defaultSceneSettings, customColor: '#f7f8f4' };
|
||||
for (const [key, field] of Object.entries(fields))
|
||||
if (value[field] !== undefined)
|
||||
Object.assign(settings, { [key]: value[field] });
|
||||
return settings;
|
||||
}
|
||||
export function k1ScenePatch(settings: SceneSettings) {
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, field]) => [field, settings[key as keyof typeof fields]]));
|
||||
}
|
||||
/** One serialized autosave owner. Scene changes never issue physical commands. */
|
||||
export function useK1SceneSettings(device: Sensor, transport: SensorTransport, failure: (error: unknown) => void) {
|
||||
const [draft, setDraft] = useState(() => k1SceneSettings(device.live_settings));
|
||||
const current = useRef(draft), dirty = useRef(false), running = useRef(false), queued = useRef(false), active = useRef(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => { active.current = true; return () => { active.current = false; queued.current = false; }; }, []);
|
||||
const stage = (patch: Partial<SceneSettings>) => { current.current = { ...current.current, ...patch }; dirty.current = true; setDraft(current.current); };
|
||||
const flush = async () => {
|
||||
if (!dirty.current)
|
||||
return;
|
||||
queued.current = true;
|
||||
if (running.current)
|
||||
return;
|
||||
running.current = true;
|
||||
setPending(true);
|
||||
try {
|
||||
while (queued.current && active.current) {
|
||||
queued.current = false;
|
||||
dirty.current = false;
|
||||
const settings = k1ScenePatch(current.current);
|
||||
await perform(transport, device, 'option', { profile: 'live-acquisition', settings });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
dirty.current = true;
|
||||
queued.current = false;
|
||||
if (active.current)
|
||||
failure(error);
|
||||
}
|
||||
finally {
|
||||
running.current = false;
|
||||
if (active.current)
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return { draft, pending, stage, flush: () => void flush(), commit: (patch: Partial<SceneSettings>) => { stage(patch); void flush(); } };
|
||||
}
|
||||
@@ -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.8"
|
||||
VERSION = "0.1.9"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: mission-core-node (>= 0.8.0), mission-core-node (<< 0.9.0),
|
||||
Depends: mission-core-node (>= 0.8.9), mission-core-node (<< 0.9.0),
|
||||
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||
Breaks: mission-core-node (<< 0.8.0)
|
||||
Replaces: mission-core-node (<< 0.8.0)
|
||||
|
||||
Reference in New Issue
Block a user