From 8283f9ba85075294cbea2c31aaa1b2d654ffeb34 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 25 Sep 2026 19:14:21 +0300 Subject: [PATCH] feat(observation): expose offline sources with transparent viewport headers --- .../src/core/fleet/observationLayout.ts | 3 +- .../observation/BoardObservationCenter.tsx | 41 +++++++++++-------- .../fleet/observation/ObservationDeck.tsx | 2 +- .../observation/ObservationDeviceSettings.tsx | 17 ++++++++ .../observation/ObservationPanelHeader.tsx | 13 ++++++ .../fleet/observation/observation.css | 7 +++- .../test/boardObservation.test.mjs | 21 +++++++++- ...0049_OBSERVATION_NAVIGATION_AND_SOURCES.md | 19 +++++++++ packages/sensor-ui/src/LiveViewport.tsx | 8 ++-- packages/sensor-ui/src/SensorDetail.tsx | 6 +-- packages/sensor-ui/src/observation.ts | 4 +- packages/sensor-ui/src/observationCatalog.ts | 11 +++++ .../xgrids-k1/frontend/src/sensors/plugin.ts | 2 +- 13 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 apps/control-station/src/workspaces/fleet/observation/ObservationDeviceSettings.tsx create mode 100644 apps/control-station/src/workspaces/fleet/observation/ObservationPanelHeader.tsx create mode 100644 docs/adr/0049_OBSERVATION_NAVIGATION_AND_SOURCES.md create mode 100644 packages/sensor-ui/src/observationCatalog.ts diff --git a/apps/control-station/src/core/fleet/observationLayout.ts b/apps/control-station/src/core/fleet/observationLayout.ts index 1497b9a..8b720ca 100644 --- a/apps/control-station/src/core/fleet/observationLayout.ts +++ b/apps/control-station/src/core/fleet/observationLayout.ts @@ -1,12 +1,13 @@ export interface ObservationLayout { version:1; order:string[]; hidden:string[]; layers:Record; - splits:Record; arrangement:'auto'|'columns'|'rows'; + catalogVisible?:string[]; splits:Record; arrangement:'auto'|'columns'|'rows'; } export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'}); const record=(value:unknown):Record=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; export function decodeObservationLayout(value:unknown):ObservationLayout { const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result; const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[]; + if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible); result.order=ids(source.order);result.hidden=ids(source.hidden); result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record; result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))])); diff --git a/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx b/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx index dc7eb0c..7ee2f30 100644 --- a/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx +++ b/apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx @@ -7,6 +7,10 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost'; import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation'; import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation'; +import {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog'; +import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions'; +import {ObservationDeviceSettings} from './ObservationDeviceSettings'; +import {ObservationPanelHeader} from './ObservationPanelHeader'; import {ObservationDeck,ObservationMount} from './ObservationDeck'; import {ObservationMap} from './ObservationMap'; import {useObservationInventory} from './useObservationInventory'; @@ -19,62 +23,65 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]); const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`; const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}}); - const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState(null); + const [settingsDevice,setSettingsDevice]=useState(null); + const [layersOpen,setLayersOpen]=useState(false); const [full,setFull]=useState(null),[expanded,setExpanded]=useState(false); const frame=useRef(null),[portal]=useState(()=>document.createElement('div')); useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]); const mounts=useRef(new Map()),media=useRef(new Map()),headers=useRef(new Map()); const groups=(inventory?.items??[]).flatMap(device=>{ - const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation; + const contribution=sensorContribution(registry.sensorContributions,device)?.observation; const views=contribution?.views(device)??cameraObservationViews(device); return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[]; }); - const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view}))); + const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view}))); + sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions)); sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}}); for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}} - const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)); + const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id))); const effectiveFull=full&&visible.includes(full)?full:null; const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry'))); - const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen; + const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice; useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]); - const active=sources.find(source=>source.key===focused); + const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice); useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]); useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]); - const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]})); + const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]})); const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id; return <>{headerToolsHost&&createPortal(,headerToolsHost)}
{createPortal(
-

Центр наблюдения и управления

{name}
{(!headerToolsHost||expanded)&&}setExpanded(value=>!value)}>
+

Центр наблюдения и управления

{name}
{(!headerToolsHost||expanded)&&}setExpanded(value=>!value)}>
{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}{effectiveFull&&}
{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}> -
{!inventory?:visible.length?setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:

Все окна скрыты.

}
+
{!inventory?:visible.length?setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:

Все окна скрыты.

}
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal( {({handle})=> - -
+ + {handle}

{source.view.label}

{source.view.layers&&setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>} - {ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return
setVisible(id,value)}/>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>} + setLayersOpen(false)} title="Доступные слои" size="md">
+ ({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/>
;})}
,portal)}; diff --git a/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx b/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx index ee52e85..47940f9 100644 --- a/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx +++ b/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx @@ -14,5 +14,5 @@ export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[ const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id)); const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length; const mid=defaultColumns&&cameraCountonSplit(key,value)} separatorLabel="Изменить размеры окон" primary={} secondary={}/>; + return onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={} secondary={}/>; } diff --git a/apps/control-station/src/workspaces/fleet/observation/ObservationDeviceSettings.tsx b/apps/control-station/src/workspaces/fleet/observation/ObservationDeviceSettings.tsx new file mode 100644 index 0000000..cef28c4 --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/observation/ObservationDeviceSettings.tsx @@ -0,0 +1,17 @@ +import {useState} from 'react'; +import {Button,Window} from '@nodedc/ui-react'; +import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail'; +import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions'; +import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts'; +import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost'; + +export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) { + const [error,setError]=useState(''); + const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail; + return + {!enabled&&

Нет связи с устройством. Настройки оборудования будут доступны после подключения.

} + {!device.prepared&&} + {error&&

{error}

} + {await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/> +
; +} diff --git a/apps/control-station/src/workspaces/fleet/observation/ObservationPanelHeader.tsx b/apps/control-station/src/workspaces/fleet/observation/ObservationPanelHeader.tsx new file mode 100644 index 0000000..356dd74 --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/observation/ObservationPanelHeader.tsx @@ -0,0 +1,13 @@ +import {useLayoutEffect,useRef,type ReactNode} from 'react'; + +/** Reserve the actual overlay height for text/status content, including wrapped controls. */ +export function ObservationPanelHeader({children}:{children:ReactNode}) { + const ref=useRef(null); + useLayoutEffect(()=>{ + const header=ref.current!; + const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`); + measure();const observer=new ResizeObserver(measure);observer.observe(header); + return()=>observer.disconnect(); + },[]); + return
{children}
; +} diff --git a/apps/control-station/src/workspaces/fleet/observation/observation.css b/apps/control-station/src/workspaces/fleet/observation/observation.css index 33a903b..eaeb095 100644 --- a/apps/control-station/src/workspaces/fleet/observation/observation.css +++ b/apps/control-station/src/workspaces/fleet/observation/observation.css @@ -9,6 +9,9 @@ .observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; } .observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; } .observation-panel__surface > .observation-mount { flex:1; } +.observation-panel__surface--overlay { position:relative; --observation-overlay-header-space:62px; } +.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; } +.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; } .observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; } .observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; } @@ -21,7 +24,7 @@ .observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; } .observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; } .observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; } -.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); } +.observation-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); } .observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; } .observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); } .observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); } @@ -43,3 +46,5 @@ .observation-panel__title { min-width:80px; } .observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; } } + +.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); } diff --git a/apps/control-station/test/boardObservation.test.mjs b/apps/control-station/test/boardObservation.test.mjs index e8e321f..d1029ee 100644 --- a/apps/control-station/test/boardObservation.test.mjs +++ b/apps/control-station/test/boardObservation.test.mjs @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import {after,before,test} from 'node:test'; import {createServer} from 'vite'; -let server,layout,source,fanout; +let server,layout,source,fanout,catalog; before(async()=>{ server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}}); layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts'); source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts'); + catalog=await server.ssrLoadModule('../../packages/sensor-ui/src/observationCatalog.ts'); ({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts')); }); after(async()=>{await server?.close();}); @@ -41,3 +42,21 @@ test('two Rerun views share bytes, apply independent blueprints once and dispose assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]); }finally{globalThis.fetch=fetchBefore;} }); + + +test('known offline D455 remains selectable; connected capabilities override the catalog',()=>{ + const offline={name:'RealSense D455',model:'RealSense D455',online:false,layers:[]}; + assert.deepEqual(source.cameraObservationViews(offline)[0].layers.map(v=>v.value),['color','depth','infrared1','infrared2','points','motion']); + assert.deepEqual(source.cameraObservationViews({...offline,online:true,layers:['depth']})[0].layers.map(v=>v.value),['depth']); + assert.deepEqual(source.cameraObservationViews({...offline,model:'unknown'}),[]); +}); +test('unbound catalog views have no device authority; connected instances replace templates without aliasing',()=>{ + const plugin={kind:'k1',observation:{catalog:{label:'K1',views:[{id:'points',label:'Points'},{id:'camera',label:'Camera'}]}}}; + const entries=catalog.unboundObservationViews([],[plugin]); + assert.equal(entries.length,2);assert.notEqual(entries[0].key,entries[1].key); + assert.equal(entries[0].deviceID,undefined);assert.equal(entries[0].device,undefined); + assert.deepEqual(catalog.unboundObservationViews([{id:'one',kind:'k1'}],[plugin]),[]); + assert.deepEqual(catalog.unboundObservationViews([],[plugin,plugin]),[]); + const persisted=layout.decodeObservationLayout({...layout.emptyObservationLayout(),catalogVisible:[entries[0].key,entries[0].key,null]}); + assert.deepEqual(persisted.catalogVisible,[entries[0].key]); +}); diff --git a/docs/adr/0049_OBSERVATION_NAVIGATION_AND_SOURCES.md b/docs/adr/0049_OBSERVATION_NAVIGATION_AND_SOURCES.md new file mode 100644 index 0000000..949f80f --- /dev/null +++ b/docs/adr/0049_OBSERVATION_NAVIGATION_AND_SOURCES.md @@ -0,0 +1,19 @@ +# 0049 — Observation overlays, offline source catalog and section navigation + +## Decision + +All apparatus observation panels place titles/actions over their viewport, without a separate header strip. A ResizeObserver reserves the real header height for status and telemetry content. DG SplitPane owns the invisible separator variant: pointer hit area, keyboard resize and keyboard focus remain available. Ratios still use the existing per-vehicle JSON layout. + +The apparatus detail and board monitor return through a shared circular header action. Explicit left-menu navigation remounts the workspace entry view, including repeated selection; persisted configuration and window arrangements are retained. Scene tool windows and recorded-session overview close. System contour selection returns to its default modules view. Leaving rover control invokes its existing cleanup/stop. + +## Sources and authority + +A known D455 with an empty offline capability list remains available as a camera viewport with its supported layer choices. A published nonempty capability list takes precedence. Detailed camera settings reuse SensorDetail; its capabilities and preview are not requested from an offline board. + +Plugins may declare a view catalog independent of physical inventory. K1 declares point cloud (3D), point cloud (top), and camera. These optional windows are listed even before enrollment, start hidden, and display an explicit disconnected-source message when selected. They never fabricate a Sensor, session, device identifier or command authority. The catalog choice is persisted in bounded catalogVisible layout data. Actual devices retain distinct device-ID-based views and replace unbound catalog entries; no ambiguous model-to-instance assignment is inferred. K1 acquisition remains an explicit operator action in its existing configurator. + +Source gear actions open the existing device settings in a draggable window. The layers dialog continues to control visibility and order. + +## Validation scope + +Build and offline UI acceptance cover section reset/back navigation, overlay headers, invisible pointer/keyboard resizing, layer visibility/persistence, disconnected notices and settings access. Unit cases cover D455 capability precedence, ambiguous plugin rejection, template/physical-device separation and bounded catalog persistence. The board was offline during this change: live RealSense/XGRIDS delivery and hardware settings are not claimed as accepted. diff --git a/packages/sensor-ui/src/LiveViewport.tsx b/packages/sensor-ui/src/LiveViewport.tsx index a1c49ea..85d0c92 100644 --- a/packages/sensor-ui/src/LiveViewport.tsx +++ b/packages/sensor-ui/src/LiveViewport.tsx @@ -3,11 +3,11 @@ import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui import {perform,type Sensor,type SensorTransport} from './contracts'; import {PointViewport} from './PointViewport'; interface Telemetry {points?:number[];motion?:Record;acquisition?:string;frame?:{observed_at:string}} -export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){ +export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage,enabled=true}:{enabled?:boolean;device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){ const video=useRef(null);const frame=useRef(null);const [expanded,setExpanded]=useState(false); const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState({}); useEffect(()=>{ - if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;} + if(!enabled||device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;} let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0; const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');}; const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);}; @@ -28,9 +28,9 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive await pc.setRemoteDescription({type:'answer',sdp:answer.sdp}); }catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}} void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);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,device.playback_id,layer,transport]); + },[enabled,device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,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'; + const active=enabled&&device.snapshot.acquisition==='streaming'; return
{state}setExpanded(!expanded)}>}> {!active?

{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}

:<> diff --git a/packages/sensor-ui/src/SensorDetail.tsx b/packages/sensor-ui/src/SensorDetail.tsx index f0ea0b5..4489584 100644 --- a/packages/sensor-ui/src/SensorDetail.tsx +++ b/packages/sensor-ui/src/SensorDetail.tsx @@ -5,7 +5,7 @@ import {LiveViewport} from './LiveViewport'; export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise;failure:(e:unknown)=>void}){ const [detail,setDetail]=useState(null);const [selected,setSelected]=useState>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState(''); async function load(){const value=await perform(transport,device,'details');setDetail(value);return value;} - useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record={};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]); + useEffect(()=>{let live=true;if(enabled&&device.online&&device.prepared)void load().then(value=>{if(live){const selection:Record={};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;};},[enabled,device.online,device.id,device.prepared,device.snapshot.context.session_id]); async function act(action:string,parameters:Record={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}} const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index))); const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))]; @@ -21,10 +21,10 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true {device.snapshot.acquisition==='stopping'&&

Сохранение исходной записи и проверка целостности.

} {device.recording&&device.snapshot.acquisition!=='stopping'&&

Идёт исходная запись на БК. Она продолжится после закрытия окна.

}
- {detail&&<>({value,label:videoNames[value]??value}))} onChange={setLayer}/>
{groups.map(group=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/>}
{detail.recordings?.length?detail.recordings.map(item=>
{new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
):

Записей пока нет.

}
} - {!detail&&

{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}

} + {!detail&&

{!enabled||!device.online?'Возможности камеры будут прочитаны после подключения.':device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}

}
; } diff --git a/packages/sensor-ui/src/observation.ts b/packages/sensor-ui/src/observation.ts index ef0efab..106b53f 100644 --- a/packages/sensor-ui/src/observation.ts +++ b/packages/sensor-ui/src/observation.ts @@ -12,10 +12,12 @@ export interface SensorObservationProps { export interface SensorObservationContribution { views:(device:Sensor)=>SensorObservationView[]; Session:ComponentType; + catalog?:{label:string;views:SensorObservationView[]}; } export const sensorLayerLabels:Record={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'}; export function cameraObservationViews(device:Sensor):SensorObservationView[] { - const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]})); + const published=device.layers.length?device.layers:/^RealSense D455$/i.test(device.model??'')?['color','depth','infrared1','infrared2','points','motion']:[]; + const layers=published.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]})); layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color')); return layers.length?[{id:'camera',label:device.name,layers}]:[]; } diff --git a/packages/sensor-ui/src/observationCatalog.ts b/packages/sensor-ui/src/observationCatalog.ts new file mode 100644 index 0000000..ed6e720 --- /dev/null +++ b/packages/sensor-ui/src/observationCatalog.ts @@ -0,0 +1,11 @@ +import type {Sensor} from './contracts'; +import type {SensorUiContribution} from './extensions'; + +/** Supported view slots are not physical devices and carry no command authority. */ +export function unboundObservationViews(devices:Sensor[],contributions:readonly SensorUiContribution[]) { + return contributions.flatMap(contribution=>{ + const catalog=contribution.observation?.catalog; + if(!catalog||devices.some(device=>device.kind===contribution.kind)||contributions.filter(other=>other.kind===contribution.kind).length!==1)return []; + return catalog.views.map(view=>({key:JSON.stringify(['catalog',contribution.kind,view.id]),catalog:catalog.label,view})); + }); +} diff --git a/plugins/xgrids-k1/frontend/src/sensors/plugin.ts b/plugins/xgrids-k1/frontend/src/sensors/plugin.ts index e9801f4..95a378b 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/plugin.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/plugin.ts @@ -8,5 +8,5 @@ export const xgridsK1SensorUi:SensorUiContribution={ kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false, supportsRenaming:false,status:k1Status, wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow}, - observation:{Session:K1Observation,views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]}, + observation:{Session:K1Observation,catalog:{label:'XGRIDS LixelKity K1',views:[{id:'points',label:'XGRIDS K1 · Облако точек · 3D'},{id:'plan',label:'XGRIDS K1 · Облако точек · Сверху'},{id:'camera',label:'XGRIDS K1 · Камера'}]},views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]}, };