feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||
|
||||
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||
return {
|
||||
atmosphere_enabled: settings.atmosphereEnabled,
|
||||
atmosphere_hue: settings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
settings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
settings.atmosphereBrightness,
|
||||
fog_enabled: settings.fogEnabled,
|
||||
fog_density: settings.fogDensity,
|
||||
sun_enabled: settings.sunEnabled,
|
||||
sun_hour: settings.sunHour,
|
||||
sun_intensity: settings.sunIntensity,
|
||||
shadows_enabled: settings.shadowsEnabled,
|
||||
monochrome_enabled: settings.monochromeEnabled,
|
||||
monochrome_color: settings.monochromeColor,
|
||||
imagery_brightness: settings.imageryBrightness,
|
||||
imagery_contrast: settings.imageryContrast,
|
||||
imagery_saturation: settings.imagerySaturation,
|
||||
imagery_gamma: settings.imageryGamma,
|
||||
imagery_hue: settings.imageryHue,
|
||||
imagery_alpha: settings.imageryAlpha,
|
||||
globe_color: settings.globeColor,
|
||||
background_color: settings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
settings.terrainExaggeration,
|
||||
buildings_color: settings.buildingsColor,
|
||||
buildings_opacity: settings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
settings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: settings.gridLodEnabled,
|
||||
grid_height_meters: settings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
settings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
settings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
settings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
settings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
settings.gridLod3StepKm,
|
||||
grid_radius_km: settings.gridRadiusKm,
|
||||
grid_line_width: settings.gridLineWidth,
|
||||
grid_color: settings.gridColor,
|
||||
grid_opacity: settings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
settings.gridDotsEnabled,
|
||||
grid_dots_size: settings.gridDotsSize,
|
||||
grid_dots_color: settings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
settings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
settings.cameraAnimationEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
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):[];
|
||||
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<string,string>;
|
||||
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))]));
|
||||
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||
return result;
|
||||
}
|
||||
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||
}
|
||||
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||
if(source<0||target<0||source===target)return layout;
|
||||
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||
if(!value)throw new Error('Аппарат не найден.');
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||
</SettingsCard>
|
||||
{!value?<ActivityIndicator label="Получаем мониторинг БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
{!value?<ActivityIndicator label="Получение мониторинга БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
<SettingsCard title="История нагрузки">
|
||||
<div className="board-monitor-controls">
|
||||
<Select label="Ресурс" value={group} options={groups.map(label=>({value:label,label}))} onChange={next=>{setGroup(next);const first=Object.entries(definitions).find(([,item])=>item.group===next);if(first)setMetric(first[0]);}}/>
|
||||
@@ -33,7 +33,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
||||
<p>{time(value.start)} — {time(value.end)} · интервал {value.bucket_seconds} с. Разрывы означают отсутствие измерений.</p>
|
||||
{!value.series.length&&<p>За выбранный период измерений нет.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидаем измерения'}>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидание измерений'}>
|
||||
<ResourceList aria-label="Показатели системы">{metrics.map(([key,item])=><li key={key}><ResourceRow title={`${item.resource} · ${item.label}`} description={latest?.values[key]==null?item.reason??'Счётчик недоступен':undefined} metadata={monitorValue(latest?.values[key],item.unit)}/></li>)}</ResourceList>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="События системы и приложения" description="За выбранный период">
|
||||
|
||||
@@ -2,21 +2,10 @@ import {useMemo} from 'react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
enrollment:{
|
||||
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations/${encodeURIComponent(id)}`),
|
||||
},
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
@@ -21,6 +22,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
@@ -42,6 +44,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
@@ -54,12 +57,13 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
|
||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||
.fleet-board-actions > * { flex:1; }
|
||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,DragDropRoot,DraggableItem,DropZone,GlassSurface,Icon,IconButton,LoadingRegion,Select,StatusBadge,Switch,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../../core/device-plugins/DevicePluginHost';
|
||||
import {createFleetSensorTransport} from '../../../core/fleet/sensorTransport';
|
||||
import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedObservationIDs} from '../../../core/fleet/observationLayout';
|
||||
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 {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
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<string|null>(null);
|
||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.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})));
|
||||
sources.push({key:'map',view:{id:'map',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 effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
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 layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||
<DropZone id={source.key} className="observation-panel">
|
||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
||||
<header className="observation-panel__header">
|
||||
{handle}
|
||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
</header>
|
||||
<ObservationMount element={media.current.get(source.key)!}/>
|
||||
</GlassSurface>
|
||||
}</DraggableItem>
|
||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>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 <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useLayoutEffect,useRef} from 'react';
|
||||
import {SplitPane} from '@nodedc/ui-react';
|
||||
import type {ObservationLayout} from '../../../core/fleet/observationLayout';
|
||||
|
||||
/** Moving a mount preserves media elements, peer lifetimes and native renderer state. */
|
||||
export function ObservationMount({element,className='observation-mount'}:{element:HTMLElement;className?:string}){
|
||||
const ref=useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(()=>{const host=ref.current!;host.append(element);return()=>{if(element.parentElement===host)element.remove();};},[element]);
|
||||
return <div ref={ref} className={className}/>;
|
||||
}
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMemo,useRef,useState} from 'react';
|
||||
import {CesiumMapRenderer,initialMapRuntimeState,type CesiumMapRendererHandle,type MapCamera} from '@nodedc/map-cesium-react';
|
||||
import {Button,Icon,IconButton,Switch,Window} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {defaultMapViewDocument} from '../../../core/map/mapView';
|
||||
import {mapPresentation} from '../../../components/map/mapPresentation';
|
||||
|
||||
export function ObservationMap({vehicleID,header}:{vehicleID:string;header:ObservationHeaderTargets}){
|
||||
const defaults=useMemo(()=>defaultMapViewDocument().view,[]),renderer=useRef<CesiumMapRendererHandle>(null);
|
||||
const [settings,setSettings]=useState(false),[state,setState]=useState(initialMapRuntimeState),[generation,setGeneration]=useState(0);
|
||||
const storageKey=`missioncore.observation-map.v1:${vehicleID}`;
|
||||
const [camera]=useState(()=>{try{const value=JSON.parse(localStorage.getItem(storageKey)??'null') as MapCamera|null;return value&&['longitude','latitude','height','heading','pitch','roll'].every(key=>Number.isFinite(value[key as keyof MapCamera]))?value:defaults.camera;}catch{return defaults.camera;}});
|
||||
const [layers,setLayers]=useState({...defaults.layerVisibility,grid:false,targets:false});
|
||||
const ready=state.phase==='ready';
|
||||
const available=Object.values(state.providers).some(provider=>provider.phase==='ready');
|
||||
return <div className="observation-source">
|
||||
<ObservationHeader targets={header} live={ready} label={ready?'Карта доступна':state.phase==='loading'?'Загрузка карты':state.phase==='degraded'?'Часть слоёв недоступна':'Карта недоступна'}><IconButton label="Слои карты" onClick={()=>setSettings(true)}><Icon name="layers"/></IconButton></ObservationHeader>
|
||||
<div className="observation-source__media observation-map" data-available={available}><CesiumMapRenderer key={generation} ref={renderer} runtimeConfigUrl="/api/v1/map/runtime-config" camera={camera} layers={layers} settings={mapPresentation(defaults.visualSettings)} cacheIntent={{enabled:true,no_overwrite:true}} onRuntimeStateChange={setState} onCameraChange={value=>{try{localStorage.setItem(storageKey,JSON.stringify(value));}catch{/* Browser storage may be unavailable. */}}}/></div>
|
||||
{!available&&state.phase!=='loading'&&<div className="observation-source__notice"><p>Не удалось загрузить картографические слои.</p><Button onClick={()=>setGeneration(v=>v+1)}>Повторить загрузку карты</Button></div>}
|
||||
<Window open={settings} onClose={()=>setSettings(false)} title="Слои карты" size="sm">
|
||||
<div className="observation-settings">{([{id:'imagery',name:'Спутниковые снимки'},{id:'terrain',name:'Рельеф'},{id:'buildings',name:'Здания OSM'}] as const).map(item=><Switch key={item.id} label={item.name} checked={layers[item.id]} onChange={value=>setLayers(current=>({...current,[item.id]:value}))}/>)}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.observation-center { display:flex; flex-direction:column; gap:var(--nodedc-space-3); padding:var(--nodedc-space-4); min-width:0; height:max(600px, calc(100dvh - 260px)); }
|
||||
.observation-center--expanded { position:fixed; inset:0; z-index:var(--nodedc-layer-overlay); background:var(--nodedc-canvas); height:100dvh; box-sizing:border-box; }
|
||||
.observation-toolbar, .observation-summary, .observation-actions { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||
.observation-toolbar { justify-content:space-between; flex-wrap:wrap; }
|
||||
.observation-toolbar h2, .observation-panel h3 { margin:0; }
|
||||
.observation-toolbar span { color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-sm); }
|
||||
.observation-actions { flex-shrink:0; }
|
||||
.observation-deck { flex:1; min-height:0; min-width:0; }
|
||||
.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__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; }
|
||||
.observation-panel__title { display:flex; align-items:center; gap:var(--nodedc-space-2); flex:1; min-width:0; }
|
||||
.observation-header-slot, .observation-header-slot > div { display:flex; align-items:center; gap:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-header-slot:has(> div:empty) { display:none; }
|
||||
.observation-layer-select { min-width:0; max-width:120px; }
|
||||
.observation-layer-select button { max-width:100%; }
|
||||
.observation-layer-select button > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.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-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); }
|
||||
.observation-layer > * { min-width:0; width:100%; }
|
||||
.observation-layer > .nodedc-switch { text-align:left; }
|
||||
.observation-empty { display:grid; place-content:center; height:100%; text-align:center; }
|
||||
.observation-decoder { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
|
||||
@media(max-width:700px) { .observation-toolbar .observation-actions { flex-wrap:wrap; } .observation-center { height:max(650px, calc(100dvh - 200px)); } .observation-center--expanded { height:100dvh; } }
|
||||
|
||||
.observation-map[data-available="false"] > [data-nodedc-map-renderer] { visibility:hidden; }
|
||||
|
||||
/* Transform the live panel as one item; reveal it across nested split boundaries. */
|
||||
.observation-panel-drag { position:relative; height:100%; min-height:0; }
|
||||
.observation-panel-drag.is-dragging { z-index:var(--nodedc-layer-panel); }
|
||||
.observation-split:has(.observation-panel-drag.is-dragging),
|
||||
.observation-split > .nodedc-split-pane__panel:has(.observation-panel-drag.is-dragging) { position:relative; overflow:visible; z-index:var(--nodedc-layer-panel); }
|
||||
@container (max-width:340px) {
|
||||
.observation-panel__header { flex-wrap:wrap; }
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function useObservationInventory(transport:SensorTransport){
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true,running=false,revision=0,last=0;
|
||||
const receive=(value:SensorInventory)=>{if(active){revision++;last=Date.now();setInventory(value);}};
|
||||
// A lost SSE connection is not evidence that a working camera stopped.
|
||||
// Retain the last inventory for one bounded heartbeat window and verify via HTTP.
|
||||
const unavailable=()=>{if(active&&Date.now()-last>15000)setInventory(value=>value?{...value,fresh:false}:value);};
|
||||
const poll=async()=>{if(running||Date.now()-last<4000)return;running=true;const before=revision;try{const value=await transport.inventory();if(before===revision)receive(value);}catch{if(before===revision)unavailable();}finally{running=false;}};
|
||||
void poll();const unsubscribe=transport.subscribe?.(receive,()=>{unavailable();void poll();});const timer=setInterval(()=>{unavailable();void poll();},5000);
|
||||
return()=>{active=false;clearInterval(timer);unsubscribe?.();};
|
||||
},[transport]);
|
||||
return inventory;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {mapPresentation} from '../../components/map/mapPresentation';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -207,60 +208,7 @@ export function WorldMapWorkspace({
|
||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||
camera={draft.view.camera}
|
||||
layers={draft.view.layerVisibility}
|
||||
settings={{
|
||||
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
|
||||
atmosphere_hue: draft.view.visualSettings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
draft.view.visualSettings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
draft.view.visualSettings.atmosphereBrightness,
|
||||
fog_enabled: draft.view.visualSettings.fogEnabled,
|
||||
fog_density: draft.view.visualSettings.fogDensity,
|
||||
sun_enabled: draft.view.visualSettings.sunEnabled,
|
||||
sun_hour: draft.view.visualSettings.sunHour,
|
||||
sun_intensity: draft.view.visualSettings.sunIntensity,
|
||||
shadows_enabled: draft.view.visualSettings.shadowsEnabled,
|
||||
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
|
||||
monochrome_color: draft.view.visualSettings.monochromeColor,
|
||||
imagery_brightness: draft.view.visualSettings.imageryBrightness,
|
||||
imagery_contrast: draft.view.visualSettings.imageryContrast,
|
||||
imagery_saturation: draft.view.visualSettings.imagerySaturation,
|
||||
imagery_gamma: draft.view.visualSettings.imageryGamma,
|
||||
imagery_hue: draft.view.visualSettings.imageryHue,
|
||||
imagery_alpha: draft.view.visualSettings.imageryAlpha,
|
||||
globe_color: draft.view.visualSettings.globeColor,
|
||||
background_color: draft.view.visualSettings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
draft.view.visualSettings.terrainExaggeration,
|
||||
buildings_color: draft.view.visualSettings.buildingsColor,
|
||||
buildings_opacity: draft.view.visualSettings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: draft.view.visualSettings.gridLodEnabled,
|
||||
grid_height_meters: draft.view.visualSettings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
draft.view.visualSettings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
draft.view.visualSettings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
draft.view.visualSettings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
draft.view.visualSettings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
draft.view.visualSettings.gridLod3StepKm,
|
||||
grid_radius_km: draft.view.visualSettings.gridRadiusKm,
|
||||
grid_line_width: draft.view.visualSettings.gridLineWidth,
|
||||
grid_color: draft.view.visualSettings.gridColor,
|
||||
grid_opacity: draft.view.visualSettings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
draft.view.visualSettings.gridDotsEnabled,
|
||||
grid_dots_size: draft.view.visualSettings.gridDotsSize,
|
||||
grid_dots_color: draft.view.visualSettings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
draft.view.visualSettings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
draft.view.visualSettings.cameraAnimationEnabled,
|
||||
}}
|
||||
settings={mapPresentation(draft.view.visualSettings)}
|
||||
cacheIntent={{
|
||||
enabled: draft.view.cacheIntent.enabled,
|
||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
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');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('layout keeps independent identities and restores absent devices without confusing equal model names',()=>{
|
||||
const a=source.observationKey('camera-1','left'),b=source.observationKey('camera-2','left'),c=source.observationKey('camera-1','right');
|
||||
assert.notEqual(a,b);assert.notEqual(a,c);
|
||||
const value={...layout.emptyObservationLayout(),order:[b,a],hidden:[a],layers:{[a]:'depth'}};
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,c]),[a,c]);
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,b,c]),[b,a,c]);
|
||||
assert.deepEqual(layout.moveObservation(value,[a,b,c],c,b).order,[c,b,a]);
|
||||
assert.deepEqual(value.hidden,[a]);
|
||||
});
|
||||
test('untrusted browser layout is bounded and validates revisions and split numbers',()=>{
|
||||
const decoded=layout.decodeObservationLayout({version:1,order:['a','a',null,8],hidden:'all',layers:{a:'depth',b:[]},splits:{a:Infinity,b:-8,c:98},arrangement:'bad'});
|
||||
assert.deepEqual(decoded.order,['a']);assert.deepEqual(decoded.hidden,[]);assert.deepEqual(decoded.splits,{b:20,c:80});assert.deepEqual(decoded.layers,{a:'depth'});assert.equal(decoded.arrangement,'auto');
|
||||
assert.deepEqual(layout.decodeObservationLayout({version:2}),layout.emptyObservationLayout());
|
||||
});
|
||||
test('camera observation exposes only advertised layers with one viewport and RGB default',()=>{
|
||||
const views=source.cameraObservationViews({name:'RealSense',layers:['depth','private-channel','color','points']});
|
||||
assert.equal(views.length,1);assert.deepEqual(views[0].layers.map(v=>v.value),['color','depth','points']);
|
||||
assert.deepEqual(source.cameraObservationViews({name:'unknown',layers:[]}),[]);
|
||||
});
|
||||
test('two Rerun views share bytes, apply independent blueprints once and dispose both hosts',async()=>{
|
||||
const fetchBefore=globalThis.fetch;globalThis.fetch=async url=>new Response(Uint8Array.of(String(url).includes('plan')?2:1));
|
||||
const sent=[[],[]],closed=[],started=[];let count=0;
|
||||
const factory=mount=>{const index=count++;return {ready:Promise.resolve({mount,viewer:{start:async()=>started.push(index),open_channel:name=>({ready:true,send_rrd:bytes=>sent[index].push([name,[...bytes]]),close:()=>{}}),get_active_recording_id:()=> 'recording-1',override_panel_state:()=>{},set_active_timeline:()=>{}}}),dispose:()=>closed.push(index)};};
|
||||
try{
|
||||
const host=fanout(factory,{current:{id:'top'}})({id:'3d'});const {viewer,mount}=await host.ready;
|
||||
await viewer.start(null,mount,{});const channel=viewer.open_channel('rrd');channel.send_rrd(Uint8Array.of(9,8));
|
||||
assert.deepEqual(sent[0],[['rrd',[9,8]]]);assert.deepEqual(sent[1],[['rrd',[9,8]]]);
|
||||
viewer.get_active_recording_id();viewer.get_active_recording_id();
|
||||
assert.deepEqual(sent[0].at(-1),['observation-perspective',[1]]);assert.deepEqual(sent[1].at(-1),['observation-plan',[2]]);
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,activation,x4;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
activation=await server.ssrLoadModule('../../packages/sensor-ui/src/observationStart.ts');
|
||||
x4=await server.ssrLoadModule('../../plugins/insta360-x4/frontend/src/observationStart.ts');
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const device=(id='camera-1')=>({id,online:true,prepared:true,layers:['color'],snapshot:{acquisition:'idle',context:{session_id:'session-1',execution:{node_id:'board-1'}}},camera_status:{connected:true,preview:0,recording:0}});
|
||||
function transport(items,submit=()=>({state:'complete'})){
|
||||
const commands=[];return {commands,inventory:async()=>({fresh:true,items}),submit:async c=>{commands.push(c);return submit(c);},operation:async()=>({state:'complete'})};
|
||||
}
|
||||
test('opening two views shares one start; another camera keeps its own lifecycle',async()=>{
|
||||
const a=device(),b=device('camera-2'),t=transport([a,b]);
|
||||
await Promise.all([activation.startCameraObservation(a,t),activation.startCameraObservation(a,t),activation.startCameraObservation(b,t)]);
|
||||
assert.equal(t.commands.length,2);
|
||||
assert.deepEqual(t.commands.map(c=>c.session.device_id).sort(),[a.id,b.id]);
|
||||
for(const c of t.commands){assert.equal(c.action_id,'start');assert.deepEqual(c.parameters,{record:false});}
|
||||
});
|
||||
test('opening observer cannot replace active capture, recording or playback',async()=>{
|
||||
for(const value of [{snapshot:{acquisition:'streaming'}},{snapshot:{acquisition:'starting'}},{snapshot:{acquisition:'stopping'}},{playback_id:'recorded-source'}]){
|
||||
const a=device();Object.assign(a,{...value,snapshot:{...a.snapshot,...value.snapshot}});const t=transport([a]);
|
||||
await activation.startCameraObservation(a,t);assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('stale inventory and replaced sessions never receive starts',async()=>{
|
||||
const original=device(),replacement=device();replacement.snapshot.context.session_id='new-session';
|
||||
for(const t of [transport([replacement]),{...transport([original]),inventory:async()=>({fresh:false,items:[original]})}]){
|
||||
await assert.rejects(activation.startCameraObservation(original,t));assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('X4 auto start uses preview only and attaches without restarting an active camera',async()=>{
|
||||
const a=device(),t=transport([a]);await x4.startX4Observation(a,t);assert.deepEqual(t.commands.map(c=>c.action_id),['preview.start']);
|
||||
a.camera_status.preview=1;t.commands.length=0;await x4.startX4Observation(a,t);assert.equal(t.commands.length,0);
|
||||
});
|
||||
test('stalled X4 preview recovers once for both lenses without any recording action',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a]);
|
||||
await Promise.all([x4.startX4Observation(a,t,true),x4.startX4Observation(a,t,true)]);
|
||||
assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop','preview.start']);
|
||||
});
|
||||
test('X4 recovery requires confirmed stopped recording and successful preview stop',async()=>{
|
||||
for(const recording of [1,null,-1]){const a=device();a.camera_status={connected:true,preview:1,recording};const t=transport([a]);await assert.rejects(x4.startX4Observation(a,t,true));assert.equal(t.commands.length,0);}
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a],()=>({state:'unknown'}));
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
test('recovery cannot start a replacement camera session after its stop',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const b=device();b.snapshot.context.session_id='replacement';
|
||||
const items=[a],t=transport(items,()=>{items[0]=b;return {state:'complete'};});
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,PreviewLiveness,previewPlayback;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({PreviewLiveness}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewLiveness.ts'));
|
||||
({previewPlayback}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewPlayback.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('offscreen RTP decode progress keeps a viewer live without DOM frame callbacks',()=>{
|
||||
const h=new PreviewLiveness(0);
|
||||
for(let now=2000;now<180000;now+=2000){h.frame(now);assert.equal(h.state(now),'live');}
|
||||
});
|
||||
test('one stale telemetry sample signals delay, never a camera restart',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);h.source(false,1100);
|
||||
assert.equal(h.state(1100),'delayed');h.source(true,1500);h.frame(1500);assert.equal(h.state(1500),'live');
|
||||
});
|
||||
test('capture recovery requires sustained fresh reports that the source stopped',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);
|
||||
for(let now=2000;now<=12000;now+=1000){h.source(false,now);assert.equal(h.state(now),'delayed');}
|
||||
h.source(false,14000);assert.equal(h.state(14000),'source-stalled');
|
||||
h.source(true,14500);h.frame(14500);assert.equal(h.state(14500),'live');
|
||||
});
|
||||
test('missing telemetry and a stalled peer cannot be treated as proof to restart capture',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(100);h.source(false,200);
|
||||
assert.equal(h.state(13000),'peer-stalled');
|
||||
});
|
||||
test('negotiation time does not consume the first-frame grace period',()=>{
|
||||
const h=new PreviewLiveness(0);h.connected(45000);
|
||||
assert.equal(h.state(45001),'waiting');assert.equal(h.state(53001),'peer-stalled');
|
||||
});
|
||||
test('a browser-paused panel resumes the same stream and coalesces pending playback',async()=>{
|
||||
const stream={},attempts=[];
|
||||
const video={srcObject:stream,muted:false,paused:true,play(){return new Promise(resolve=>attempts.push(resolve));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();playback.resume();assert.equal(attempts.length,1);assert.equal(video.muted,true);
|
||||
video.paused=false;attempts[0]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,1);
|
||||
video.paused=true;playback.resume();assert.equal(attempts.length,2);assert.equal(video.srcObject,stream);
|
||||
playback.dispose();attempts[1]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,2);
|
||||
});
|
||||
test('hidden-panel play rejection is retried on visibility without replacing the stream',async()=>{
|
||||
const stream={};let attempts=0;
|
||||
const video={srcObject:stream,muted:true,paused:true,play(){attempts++;return Promise.reject(new Error('offscreen'));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(attempts,2);assert.equal(video.srcObject,stream);
|
||||
video.srcObject=null;playback.resume();assert.equal(attempts,2);
|
||||
});
|
||||
@@ -35,6 +35,9 @@
|
||||
],
|
||||
"@mission-core/sensor-sdk": [
|
||||
"../../packages/sensor-ui/src/pluginSdk.ts"
|
||||
],
|
||||
"react-dom": [
|
||||
"node_modules/@types/react-dom/index.d.ts"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
],
|
||||
"@mission-core/sensor-sdk": [
|
||||
"../../../packages/sensor-ui/src/pluginSdk.ts"
|
||||
],
|
||||
"react-dom": [
|
||||
"node_modules/@types/react-dom/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1792,3 +1792,133 @@ Fresh Core readback: same vehicle/node/device IDs, both cameras online;
|
||||
D455 streaming; X4 idle/preview0/recording0/recoveryfalse/preview_wantedfalse.
|
||||
Owner informed installation completed and no further password needed; only
|
||||
Power-off with USB retained is awaited for MANUAL02. No new wake issued yet.
|
||||
|
||||
|
||||
## 2026-09-10 — OBS01: board observation UI qualification
|
||||
|
||||
Owner requested a board-scoped multi-panel observation center. Runtime baseline:
|
||||
D455 capture streaming, X4 preview0/recording0/recovery disabled. The product UI
|
||||
issued bounded X4 preview.start/preview.stop operations only; no SD recording,
|
||||
photo, preparation, SDK installation or Ubuntu runtime edits. Existing Node
|
||||
0.8.21-3/X4 0.1.3-9 remains the installer-owned baseline.
|
||||
|
||||
Qualified the published X4 frame as a 1280×640 pair of fisheye views. The new
|
||||
observer uses one WebRTC peer/decoder and two canvas crops, without stitching.
|
||||
A WebKit hidden-video pause was caught during QA and fixed with explicit muted
|
||||
playback; both lens views then showed live images alongside D455. Crops are
|
||||
frame halves, not a proved chassis-relative left/right camera mounting.
|
||||
|
||||
Repeated source changes exposed Core's 32-command limit counting completed
|
||||
receipts for ten minutes. Local Core correction retains 32 active slots and
|
||||
bounds receipt history at 128; only expired terminal receipts can be reclaimed.
|
||||
34 fleet tests cover completion, saturation and replay rejection. No Node patch
|
||||
was required. Canonical LaunchAgent apply used identical predecessor/desired
|
||||
plist SHA 6b651aedb54236f76bf062a2319d9223f0b00f7d264451b3bbaccbee0896e065;
|
||||
Core PID74340→85056, health accepted. Apply report SHA256
|
||||
78280b731a41e4f78df1174b3363920118f7dd9e4b178e33dbebe69be0deb590.
|
||||
|
||||
Final API readback: board online, D455 streaming, X4 idle/preview0/recording0.
|
||||
No temporary capture or Ubuntu job remains. User-facing observer retains the
|
||||
original D455 live source. Private timestamped/monotonic acceptance report SHA256
|
||||
6d51159c3eff97e19297c71cef40a57e391e2e7aacbac1ff0909cfdcfcea6922.
|
||||
|
||||
Validation: 826 Control Station tests, 8 focused architecture/observer tests,
|
||||
Control Station and Node UI types, production build; browser normal/expanded,
|
||||
Escape priority, layer selection, hide/restore, split and ordering persistence.
|
||||
Real K1 dual-view and server map cache acceptance are not claimed: K1 absent,
|
||||
configured local Map Gateway18103 unavailable. See 08_BOARD_OBSERVATION_CENTER.md.
|
||||
Rollback: restore predecessor source/build and reload the same canonical Core
|
||||
through the LaunchAgent lifecycle; do not edit the board or SDK. Layout v1 is
|
||||
browser-local and independent for each vehicle; reset uses the product control.
|
||||
|
||||
### OBS02 — direct observer preview start, 2026-09-10
|
||||
|
||||
User reported blank X4 panels after OBS01 left preview stopped. Added one shared
|
||||
preview.start action to X4Observation, exposed in both lens panels with common
|
||||
pending/error state and an in-flight guard. Uses the existing versioned operation
|
||||
transport and exact device/session identity. No installer, Node service, SDK,
|
||||
recording or Ubuntu changes. Closing observer panels still releases viewer peers
|
||||
only and does not stop camera capture.
|
||||
|
||||
Actual GUI start accepted: one 1280×640 decoder, two live lens canvases, D455 live.
|
||||
Current final readback supersedes OBS01 cleanup state: X4 online/streaming,
|
||||
preview=1, recording=0. Preview is durable operator state for this request and
|
||||
is intentionally left on. Frontend build/typecheck passed. Private evidence
|
||||
SHA256 9fef991a516ab516e7530c7c1b3cfb80056a619216d7d899ee67ea81ba44022b.
|
||||
Rollback is frontend-only: remove the action and rebuild; it does not mutate
|
||||
the prepared board or stop an ongoing preview.
|
||||
|
||||
### OBS03 — automatic opening and stalled-preview recovery, 2026-09-10
|
||||
|
||||
Owner explicitly requires camera initialization on opening Board Observation.
|
||||
X4 and D455 now activate preview automatically from fresh exact-session inventory.
|
||||
Two X4 lens windows share one start and one peer. Already streaming capture is
|
||||
preserved. RealSense start uses record=false; X4 uses preview.start only.
|
||||
Viewer retries are bounded separately from camera-source recovery. Confirmed X4
|
||||
source staleness triggers preview stop/start only with recording=0, with session
|
||||
revalidation, stop-failure short circuit and two-attempt budget until stable live.
|
||||
These are existing installer-owned API operations; no SDK, service, library,
|
||||
configuration, package or Ubuntu mutation was needed.
|
||||
|
||||
Live acceptance: original X4 stale source recovered without a manual start;
|
||||
then GUI stopped both cameras, API confirmed both idle and X4 preview0/recording0,
|
||||
and one Center opening started D455 RGB640×480 plus both X4 lenses1280×640.
|
||||
No manual preview-start click was used. Final both online/streaming, X4 preview1,
|
||||
recording0, no D455 recording. Previews remain on for the operator.
|
||||
11 focused tests, Control Station build/types and Node UI types passed. Evidence
|
||||
SHA256 c99c2e929618df21725ea1b8dcce08b242b6ab49f1b4b3b9cb5a125b1bad7124.
|
||||
Rollback: restore prior frontend and rebuild; no board reinstall or cache reset.
|
||||
|
||||
### OBS04 — viewer stability and presentation, 2026-09-10
|
||||
|
||||
Fixed false peer/source recovery when a hidden or clipped X4 decoder stopped
|
||||
requestVideoFrameCallback while RTP decoding continued. A two-second decoded-frame
|
||||
check now runs independently of DOM painting. A stale source sample retains the
|
||||
last image with a delay status; source restart requires twelve continuous seconds
|
||||
of stale fresh telemetry. Missing telemetry and peer failure do not authorize
|
||||
capture restart. Peer failure closes the previous peer before bounded backoff.
|
||||
Inventory SSE loss gets a bounded fifteen-second grace and HTTP verification.
|
||||
|
||||
Both old and new frontend instances were inspected; the stale Chrome build was
|
||||
reloaded so it could no longer independently restart shared capture. From
|
||||
17:27:49.636Z through 17:52:39.435Z, D455 media time advanced 154.753→1644.621 s
|
||||
and X4 123.635→1613.433 s, without reset in the observed samples. Both final
|
||||
videos were unpaused, readyState=4, D455640×480 / X41280×640. This demonstrates
|
||||
sustained decoding across the interval, not zero dropped frames. No Ubuntu, SDK,
|
||||
service or recording mutation. Prepared Node0.8.21-3/X4plugin0.1.3-9 preserved.
|
||||
|
||||
Service notices reduced 30% to 11.2px, first-person UI text removed, and failed
|
||||
Cesium canvas hidden while its panel retains the same complete rounded black
|
||||
surface. Control Station 838 tests, focused20, both frontend typechecks and
|
||||
production build passed. Canonical Core8000 health remained ok. Private acceptance
|
||||
SHA256 d885939be21b398970c41e5471d2580c0d10493f4dbff807bbdbe94790f64739.
|
||||
|
||||
NAS map access is explicitly owner-approved and separately staged through Platform
|
||||
runner; it has not yet been applied. Camera acceptance does not claim map success.
|
||||
Rollback is restoration of prior frontend source/build using canonical lifecycle;
|
||||
no board reinstall or automatic capture stop is required.
|
||||
|
||||
### OBS04B — resume browser-paused RealSense after map expansion
|
||||
|
||||
Final visual QA reproduced a distinct presentation failure: at18:10:12Z the
|
||||
RealSense element was paused at media1996.686s while RTP was still decoding
|
||||
and the status said live. Map expansion temporarily detaches the panel portal;
|
||||
WebKit can pause its video. Added previewPlayback to resume the same muted
|
||||
stream on a two-second presentation check/visibilitychange. Pending play calls
|
||||
are coalesced, rejected hidden playback retries later, disposed views do not
|
||||
resume. A paused visible video no longer receives a live badge. This action
|
||||
never replaces a peer or issues camera capture/recording commands.
|
||||
|
||||
Focused18 tests, full840, both frontend typechecks and production build passed.
|
||||
New asset app-DGION5Hm.js loaded on canonical Core8000. Before map expansion
|
||||
18:14:26.463Z media timesD45536.228/X436.262. Map held expanded until
|
||||
18:14:51.338Z, then restored. At18:15:52.519Z both were ready4/unpaused,
|
||||
mediaD455122.339/X4122.355 with no timeline reset. Fresh fleet readback: both
|
||||
online/streaming, X4preview1/recording0, no D455 recording. Node/SDK unchanged.
|
||||
Private evidenceSHA b1d3c3c382866304ccac80e8cab9b49ca1a0ea5836af715baf96b9f3b4923ef9.
|
||||
|
||||
Map network acceptance also completed: after Windows connector became healthy,
|
||||
24 new buildings objects (15161307bytes) were appended to NAS cache, entries
|
||||
71774→71798/bytes1519820206→1534981513, while failure count remained5. Previous
|
||||
AMD connection refusal is resolved; cold data still requires its documented
|
||||
Windows Docker/VPN path. Cached map and all camera views left open.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# Бортовой центр наблюдения
|
||||
|
||||
Product-surface brief, 10.09.2026. Владелец прямо запросил эту композицию и оба
|
||||
входа: в строке аппарата и в карточке БК. Новая верхняя навигация не требуется.
|
||||
|
||||
- Задача оператора: одновременно видеть визуальные источники выбранного борта.
|
||||
Entity — борт; источники принадлежат конкретным device/session identities.
|
||||
- Выбран отдельный режим внутри «Парк → Аппараты», с возвратом к конфигуратору.
|
||||
Альтернатива — расширить конфигуратор — отклонена: формы мешают наблюдению и
|
||||
отнимают место у видео. Новый product root не нужен.
|
||||
- Источники приходят из inventory и model contributions. RealSense — одно окно
|
||||
с выбором реально объявленного слоя; X4 — две половины парного кадра; K1 —
|
||||
Rerun Point Cloud 3D, отдельный ракурс сверху и опубликованная Cam Right.
|
||||
- Карта — общий @nodedc/map-cesium-react; presentation mapper переиспользуется
|
||||
с WorldMapWorkspace. Все provider-запросы идут через /api/v1/map/gateway,
|
||||
cacheIntent enabled/no_overwrite=true. OSM buildings asset 96188, imagery 2,
|
||||
terrain 1. Координаты борта не выдумываются: без GNSS маркера аппарата нет.
|
||||
- Одна preview-сессия на пару X4/K1. Перекомпоновка меняет только размещение
|
||||
существующих DOM mounts, не захват или identity. Видео использует существующий
|
||||
WebRTC transport, K1 — существующий Rerun host/stream. Это единая рабочая область
|
||||
разных renderer-ов, не конвертация видео в RRD и не новый recorder.
|
||||
- Контролы: Button/IconButton (eye, sliders, activity, settings, expand/minimize,
|
||||
close), Select, Switch, GlassSurface, ApplicationPanel, SplitPane,
|
||||
DragDropRoot/DraggableItem/DropZone из NODE.DC Design Guideline. Новых shared
|
||||
visual entities нет. Drag меняет порядок панелей, SplitPane — размеры.
|
||||
- Состояния: loading, нет визуальных устройств, все окна скрыты, источник offline,
|
||||
preview idle, connecting, live, stale, error/retry. Скрытие не останавливает
|
||||
запись. По уточнённому требованию владельца открытие центра автоматически
|
||||
запускает предпросмотр подготовленных RealSense/X4; запись не запускается.
|
||||
- Layout принадлежит борту и локальному браузеру: порядок, скрытые источники,
|
||||
выбранные слои, проценты split и режим компоновки; fullscreen — временный.
|
||||
- Приёмка: оба входа, три горизонтальные кнопки, адаптивный состав панелей по inventory,
|
||||
разные экземпляры, split/drag/hide/restore, слои, fullscreen/Escape, persistence,
|
||||
свежий RealSense/X4 поток, автоматическая активация preview и отсутствие record effects.
|
||||
|
||||
Референс поведения: https://docs.foxglove.dev/docs/visualization/panels и
|
||||
https://docs.foxglove.dev/docs/visualization/layouts. Все три присланных примера просмотрены в авторизованном Chrome: UGV, Argo
|
||||
и quadruped. Проверены многопанельная композиция, отдельные камеры, карта,
|
||||
вид сверху и свободный 3D. Временная вкладка закрыта. Зависимость Foxglove от Rerun
|
||||
не установлена и не является основанием реализации.
|
||||
|
||||
Read-only baseline: Core 8000 alive, Rover online, D455 streaming, X4 idle.
|
||||
Проверка через штатный UI: X4 preview.start → живое видео 1280×640 с двумя
|
||||
fisheye-изображениями → preview.stop подтверждён доступной кнопкой START.
|
||||
SD recording не запускалась; SDK/runtime/Ubuntu не менялись. Left/Right относятся
|
||||
к половинам опубликованного кадра, не к доказанному положению камеры на шасси.
|
||||
|
||||
## Реализация и проверка 10.09.2026
|
||||
|
||||
- Два входа в центр и три горизонтальные кнопки в карточке БК.
|
||||
- Стабильные portal mounts сохраняют video/canvas/native viewer при смене
|
||||
split, порядка и fullscreen. Скрытие всей группы отключает viewing peer,
|
||||
но не захват и не запись. Выход закрывает peer штатной close-peer.
|
||||
- Layout v1 привязан к vehicle ID: order, hidden, selected layer, arrangement,
|
||||
bounded split proportions. Неизвестные/повреждённые browser values проверяются.
|
||||
- Для K1 один RRD peer раздаёт байты двум изолированным Rerun realms. Собственные
|
||||
RBL assets содержат только конфигурацию; generator использует закреплённый
|
||||
Rerun 0.36.3 и application ID существующего bridge. Реальный K1 отсутствует
|
||||
в текущем inventory: аппаратная приёмка двух ракурсов остаётся открытой.
|
||||
- Реальный D455: RGB 640×480, глубина, оба ИК-канала, облако точек и IMU показаны
|
||||
в центре. X4: один WebRTC decoder 1280×640 и два живых crop canvas. WebKit
|
||||
требовал явного play() для decoder video; исправлено и подтверждено кадрами.
|
||||
- Fullscreen вынесен в стабильный body portal: родительская transform больше
|
||||
не ограничивает viewport; использован canonical overlay layer. Escape сначала
|
||||
принадлежит открытым диалогам/спискам, затем восстанавливает центр/панель.
|
||||
- Frontend: 826 tests passed; дополнительный architecture/observation pass 8/8;
|
||||
типы Control Station и Node UI проходят. Production build проходит; остался
|
||||
прежний bundler advisory по размеру основных чанков.
|
||||
- Общий UI package пересобран из уже существующих source, потому что его dist
|
||||
types отставали от LoadingRegion/loading controls. Source DG не менялся.
|
||||
|
||||
## Существующая очередь операций Core
|
||||
|
||||
Во время проверки много переключений слоёв заполнили 32 receipt slots на десять
|
||||
минут. Это блокировало даже preview.stop, хотя операции уже завершились.
|
||||
Исправлен учёт: максимум 32 одновременно ожидающие/исполняемые команды сохранён;
|
||||
receipt storage отдельно ограничен 128. При заполнении удаляются только terminal
|
||||
receipts с истёкшим deadline. Их повторная отправка не может снова выполнить
|
||||
команду, поскольку просроченный request отклоняется. Идемпотентность живых receipts
|
||||
сохранена. 34 fleet tests проходят, включая завершённые, активные и replay cases.
|
||||
|
||||
## Карта: ограничение текущего окружения
|
||||
|
||||
Канонический Core настроен на http://127.0.0.1:18103. На этом адресе нет listener;
|
||||
/api/v1/map/gateway/health возвращает 502 map_gateway_unavailable. Поэтому live
|
||||
приёмка тайлов/OSM buildings и записи cache пока не подтверждена. Новое окно
|
||||
показывает недоступность и повтор загрузки, не обходит Gateway публичными tile URL.
|
||||
Серверный shared Gateway уже поддерживает persistent live-tile-cache и отдельный
|
||||
read-only offline-snapshot на NAS; это подтверждено source/compose, но не текущим
|
||||
состоянием сервера. Docker/другой сервер в обход контура не запускался.
|
||||
|
||||
Итоговый Core apply: PID74340→85056, health accepted; plist identity сохранена.
|
||||
Финальный readback: борт online, D455 streaming, X4 idle/preview0/recording0.
|
||||
Acceptance report SHA256
|
||||
6d51159c3eff97e19297c71cef40a57e391e2e7aacbac1ff0909cfdcfcea6922.
|
||||
|
||||
## Уточнение 10.09.2026: запуск X4 и фактический сервер карты
|
||||
|
||||
Предыдущая приёмка оставила X4 в idle, поэтому пользователь получил пустые окна.
|
||||
В X4Observation добавлен «Начать просмотр» непосредственно в обоих окнах. Оба
|
||||
действия используют один общий pending/ref guard и одну preview.start operation
|
||||
для конкретной camera session; изображения по-прежнему обслуживает один peer.
|
||||
Ошибки запуска показываются в тех же окнах. Закрытие окна не останавливает capture.
|
||||
Start проверен через текущий GUI: один decoder 1280×640, оба canvas показывают
|
||||
живые объективы, D455 продолжает работать. Финальный API: X4 online,
|
||||
acquisition=streaming, preview=1, recording=0. Предпросмотр оставлен оператору.
|
||||
Control Station typecheck/build проходят; Ubuntu/SDK не изменялись.
|
||||
|
||||
DCPLATFORM-61 подтвердил владельца карты: общий Platform Map Gateway на NAS,
|
||||
а не отдельный Device Core cache. Read-only SSH health подтвердил service=ok,
|
||||
ionConfigured=true, persistent readwrite cache: 71774 entries, 1519820206 bytes,
|
||||
в том числе imagery/terrain/3D binary. Gateway слушает только NAS 127.0.0.1:18103.
|
||||
Это проверка наличия cache, не новая визуальная приёмка карты или cold egress.
|
||||
|
||||
Подготовлен scripts/manage_map_gateway_link.py: локальный user LaunchAgent,
|
||||
строго loopback 18103 → SSH → NAS loopback 18103, без remote command,
|
||||
agent forwarding и provider credentials. StrictHostKeyChecking/BatchMode,
|
||||
reconnect, exact SHA plan/apply, health gate, rollback и запрет занятого порта.
|
||||
Три regression tests проверяют ограничение транспорта, устаревший plan и
|
||||
откат первого неуспешного bootstrap.
|
||||
|
||||
Live apply не прошёл health gate: NAS /etc/ssh/sshd_config содержит глобальный
|
||||
AllowTcpForwarding no; исключения есть только для root/admin, текущий account
|
||||
не входит в них. SSH возвращает administratively prohibited. Проверенный
|
||||
rollback удалил кандидат LaunchAgent; loaded=false, listener18103 отсутствует,
|
||||
Core8000 healthy. Настройки NAS не изменены и запрет не обходился.
|
||||
|
||||
Следующий gate — разрешение администратора NAS на ограниченный local forwarding
|
||||
для существующего account с PermitOpen=127.0.0.1:18103. Global forwarding,
|
||||
reverse/dynamic forwarding и публикация Gateway в LAN/WAN не требуются.
|
||||
После принятого изменения нужны sshd syntax/effective-policy checks,
|
||||
повторный exact local plan/apply, same-origin health и визуальная приёмка
|
||||
imagery/terrain/buildings с подтверждением NAS cache hits. До этого карта
|
||||
считается недоступной; наличие серверного cache не закрывает её acceptance.
|
||||
|
||||
Private evidence SHA256:
|
||||
9fef991a516ab516e7530c7c1b3cfb80056a619216d7d899ee67ea81ba44022b.
|
||||
|
||||
## Автоматическое наблюдение — уточнение владельца 10.09.2026
|
||||
|
||||
Открытие Центра означает запрос live-данных. Подготовленные online RealSense/X4
|
||||
автоматически запускают preview; пользовательская кнопка нужна только при ошибке.
|
||||
Уже активный capture/playback не перезапускается. RealSense использует driver-owned
|
||||
default profiles с record=false. X4 использует preview.start. SDK preparation,
|
||||
firmware, запись на БК/SD и запуск нового K1 project сюда не включены: K1 start
|
||||
имеет отдельную семантику acquisition/recording с именем проекта, его существующий
|
||||
активный поток подключается при открытии центра.
|
||||
|
||||
Shared observationStart/useObservationStart проверяют свежую inventory и exact
|
||||
node/device/session перед действием, объединяют in-flight действия двух окон и
|
||||
React remount, не повторяют неуспешный start бесконечно. Новая device session или
|
||||
новое открытие разрешает новую попытку. Скрытые пользователем источники сохраняют
|
||||
свой layout и не требуют нового peer до показа.
|
||||
|
||||
useSensorPreview различает оборванный viewer и подтверждённый stale source.
|
||||
Viewer reconnect ограничен четырьмя попытками с backoff 2/4/8/16 s (включая
|
||||
30 s expiry старого X4 peer после reload). Фоновая вкладка не объявляется source
|
||||
failure из-за остановленного browser presentation. X4 при живой telemetry и
|
||||
отсутствии кадров более 8 s восстанавливает preview.stop → preview.start,
|
||||
только с подтверждённым recording=0. Stop failure или смена session не допускают
|
||||
последующий START. Допускаются две последовательные попытки; бюджет сбрасывается
|
||||
после 30 s live. Запись не останавливается и не запускается.
|
||||
|
||||
Live QA: воспроизведён исходный stale X4 при preview=1; новое окно самостоятельно
|
||||
восстановило оба объектива. Затем штатными UI controls остановлены D455 и X4.
|
||||
API перед открытием: обе idle, X4 preview=0/recording=0. Единственное действие —
|
||||
«Центр наблюдения» — автоматически дало D455 RGB640×480 и два X4 canvas из одного
|
||||
1280×640 decoder. Ручных start clicks=0. Повторный вход к активному capture
|
||||
проверен. Final state: обе online/streaming, X4 preview=1/recording=0,
|
||||
recording_active=false. Ubuntu runtime не менялся, Core8000 сохранён.
|
||||
|
||||
11 focused tests проходят: independent identities, no duplicate start,
|
||||
active/playback preservation, stale/session rejection, preview-only recovery,
|
||||
unknown/active recording guard, stop failure и replacement after stop; также
|
||||
layout/blueprint tests. Control Station build/typecheck и Node UI typecheck passed.
|
||||
Private acceptance SHA256:
|
||||
c99c2e929618df21725ea1b8dcce08b242b6ab49f1b4b3b9cb5a125b1bad7124.
|
||||
|
||||
## OBS04: стабильность и доступ к карте — 10.09.2026
|
||||
|
||||
Уточнение заменяет прежний порог восстановления source: требуется 12 секунд
|
||||
непрерывной stale telemetry, а не единственный устаревший снимок. RTP decoded
|
||||
frames проверяются каждые 2 секунды независимо от callback рисования скрытого
|
||||
X4 video. Последнее изображение сохраняется при задержке; ошибки peer и
|
||||
отсутствие telemetry не перезапускают capture. SSE inventory имеет ограниченный
|
||||
15-секундный grace с HTTP-проверкой. Старый Chrome frontend перезагружен.
|
||||
|
||||
Фактические DOM samples 17:27:49→17:52:39Z показывают рост media time обеих
|
||||
камер без сброса. Итог: D455 640×480, X4 1280×640, обе ready4/unpaused.
|
||||
838 frontend tests, focused20, оба typecheck и build passed. Уведомления
|
||||
уменьшены на 30% до11.2px и используют безличные формулировки. Неисправный
|
||||
Cesium canvas скрыт, поверхность карты остаётся полным чёрным прямоугольником.
|
||||
Evidence SHA256 d885939be21b398970c41e5471d2580c0d10493f4dbff807bbdbe94790f64739.
|
||||
|
||||
Владелец разрешил только доступ к сервису карты. NAS доступен через Tailscale,
|
||||
общий cache и AMD/VPN proxy здоровы. Подготовлен отдельный Platform component
|
||||
mission-core-map-access: payload access.json, никаких ключей или файлов SSH
|
||||
в пакете. Source commit11b73e64681f83aa2ebfd89ef266d3be2869f7e1. Candidate runner
|
||||
f21936f131365bd3993e2d746e332538d628ba50ef62c1cce833161171ffde47; artifact
|
||||
nodedc-mission-core-map-access-20260910-001.tgz SHA256
|
||||
6904cbbc2809046ef47ae904571f86bfbec69e25b536c4e4e1a9fab55962fe50.
|
||||
SMB staging подтверждён, lifecycle=staged. Live predecessor runner подтверждён
|
||||
владельцем:8a43f7f945fc7fa79a4bc91ec91f15e39119e519b332dac450cf60e0681338cd.
|
||||
Ожидаются отдельный root promotion, fresh plan/apply, затем local link apply
|
||||
и визуальная приёмка карты. До этого карта не объявляется подключённой.
|
||||
|
||||
## Доступ карты применён — 10.09.2026, 17:56:41Z
|
||||
|
||||
Предыдущий pending gate закрыт. Владелец выполнил atomic root promotion и
|
||||
verify-install с ожидаемым runner SHA f21936f1…, затем fresh plan и apply.
|
||||
Runner завершил deploy-ok patch=mission-core-map-access-20260910-001; backup
|
||||
mission-core-map-access-mission-core-map-access-20260910-001-20260910-205639.
|
||||
SMB inspector подтвердил terminal_success/applied.jsonl line381 и совпадение
|
||||
artifactSHA. Повторно применять или restage этот artifact нельзя.
|
||||
|
||||
Mac link применён из absent predecessor, loaded/healthy=true, listener только
|
||||
127.0.0.1:18103. Core same-origin health ok; Cesium отображается в Центре рядом
|
||||
с камерами. Все три provider подключились, fullscreen/restore и hide/restore
|
||||
карты проверены. Cache inventory71774entries/1519820206bytes сохранён. Два
|
||||
чтения terrain metadata:200,920353bytes,одинаковыйSHA,cacheStaleHits194→196,
|
||||
egressRequests5→5. Это подтверждение чтения сохранённого cache.
|
||||
|
||||
Первоначальный отказ обновления provider был отдельным инцидентом AMD:
|
||||
connector172.22.0.183:8791 возвращал ECONNREFUSED. Скриншот владельца показал
|
||||
healthy container после недавнего запуска с правильным IP/port. После этого
|
||||
terrain и buildings endpoints получили ion-endpoint-online за1.434s и0.584s.
|
||||
NAS proxy pairing и directEgress=false не менялись. Новые ключи выдаются только
|
||||
внутри NAS, browser видит credentialMode=gateway. Один контрольный health502
|
||||
восстановился при следующем чтении; нулевой процент ошибок не заявляется.
|
||||
Private deployment/product evidenceSHA 22f403608d7aca9cf38c070f171e8fa876a29d4224109d9a499d0b29663eb9c6.
|
||||
|
||||
## Финальная приёмка OBS04B
|
||||
|
||||
Cold cache fill подтверждён после восстановления AMD:24 новых objects зданий,
|
||||
15161307bytes,cacheEntries71774→71798,новых upstream errors0. Все три слоя
|
||||
Cesium подключены; серверный readwrite append-only cache сохраняется.
|
||||
|
||||
При разворачивании карты WebKit приостанавливал временно отсоединённый D455
|
||||
video. previewPlayback возобновляет тот же stream без restart capture/peer;
|
||||
пауза больше не отображается как live. Две дополнительные regression checks
|
||||
и полный набор840tests/typechecks/build проходят. Map expand25s→restore
|
||||
подтверждён на живом борту: обе камеры ready4/unpaused, media timestamps
|
||||
продолжают расти. Подробности OBS04B installation ledger; evidenceSHA
|
||||
b1d3c3c382866304ccac80e8cab9b49ca1a0ea5836af715baf96b9f3b4923ef9. Canonical Core8000 и map-link оставлены работающими.
|
||||
|
||||
## OBS05: компактные заголовки — 10.09.2026
|
||||
|
||||
По указанию владельца удалены отдельные строки «Прямой эфир» и повторная
|
||||
подпись карты. Единственный StatusBadge variant=indicator находится сразу
|
||||
после названия источника. Его цвет отражает исходное liveness-состояние,
|
||||
а точное состояние доступно через aria-label и tooltip. Переходы подключения,
|
||||
задержки и восстановления не объявляются зелёным live автоматически.
|
||||
|
||||
Выбор слоя RealSense перенесён к правым действиям заголовка. Кнопка «Слои
|
||||
карты» открывает прежние switches imagery/terrain/buildings; дублирующая
|
||||
шестерёнка у карты удалена. Согласованный glyph layers сначала добавлен в
|
||||
Design Guideline Icon, registry, документацию и живой каталог. Геометрия
|
||||
кнопок и индикаторов остаётся канонической.
|
||||
|
||||
BoardObservationCenter передаёт стабильные statusTarget/actionsTarget вместе
|
||||
с media target. CameraObservation, X4Observation, K1Observation и карта
|
||||
публикуют статус через ObservationHeader. Эти слоты не меняют владельца
|
||||
capture, peer или renderer. Ошибки сохраняют доступное действие повтора в
|
||||
заголовке; последнее изображение остаётся в media. Вся высота после шапки
|
||||
принадлежит media, aspect ratio изображений сохранён через contain.
|
||||
|
||||
Проверены 4 architecture checks, оба UI typecheck, 840 frontend tests,
|
||||
production build (app-mCmQLf6P.js), DG registry/icon check и catalog build.
|
||||
Нормальный и развёрнутый центр проверены на живых D455/X4/Cesium; RGB →
|
||||
глубина → RGB, окно слоёв карты, Escape. На ширине панели389px заголовок62px,
|
||||
media326.5px, дополнительных body rows0, gap после заголовка0; статусная
|
||||
точка отстоит от названия на8px. Длинные названия сокращаются с tooltip.
|
||||
Во время подключения X4 был серый delayed/reconnecting, затем оба объектива
|
||||
перешли в live; цвет не подменялся для визуальной приёмки.
|
||||
|
||||
После map expand/restore (18:41:04Z) все источники доступны, оба video
|
||||
ready4/unpaused, gap0. Контроль18:42:33Z снова показал восстановление X4 при
|
||||
сохранённом изображении; RealSense live и карта ready. OBS05 закрывает правки
|
||||
интерфейса, но не доказывает непрерывность X4. Существующая preview recovery
|
||||
не изменялась. Core8000 и Map Gateway health успешны.
|
||||
|
||||
## OBS06: настройки окон и перенос всей панели — 10.09.2026
|
||||
|
||||
Выбор позиции каждого окна занимает50% ширины общей формы, независимо от
|
||||
длины названия. Проверено на семи источниках после появления K1:
|
||||
верхнее поле523.21875px, каждый select261.609375px. Слои карты содержат
|
||||
только три переключателя; технические пояснения и лишний health polling
|
||||
этого окна удалены. Сам Gateway, серверный cache и настройки egress не менялись.
|
||||
|
||||
В Design Guideline исправлен общий Switch: включённый бегунок использует
|
||||
on-accent, выключенный track следует тону темы, track не сжимается из-за
|
||||
подписи. В Mission Core checked track rgb(247,248,244), thumb rgb(8,8,10),
|
||||
ширина32px; выключенное состояние визуально различимо. Обновлены canonical
|
||||
styles.css, registry/components.json, docs/COMPONENTS.md и catalog specimen.
|
||||
|
||||
DraggableItem охватывает GlassSurface с заголовком и живым media target.
|
||||
DragHandle остаётся единственным инициатором. Временно раскрывается overflow
|
||||
только у веток SplitPane с активным переносом; медиа не клонируется и не
|
||||
перезапускается. Mid-drag DOM samples на конечной сборке подтверждают
|
||||
одинаковые headerX/mediaX при dx49/73/97px и gap0. Drop переставляет весь
|
||||
источник; исходный порядок восстановлен. После появления семи окон
|
||||
добавлен перенос действий в панелях <=340px; названия больше не вытесняются
|
||||
кнопками. В широкой панели778.625px сохраняется один header высотой62px.
|
||||
|
||||
Проверки: architecture4/4, оба UI typecheck, frontend840/840, production build
|
||||
app-Lo_50rZQ.js, DG registry validation и catalog build. Live QA: форма из7
|
||||
строк, map OSM off/on с восстановлением, оба направления drag с media,
|
||||
обычный/развёрнутый центр, отдельная широкая панель и Escape. Финальный
|
||||
DOM18:59:15Z: обе камеры ready4/unpaused, media50.057s/50.081s, dragging0.
|
||||
K1 обнаружен, три его viewport доступны, acquisition не запускался этой
|
||||
правкой. Runtime БК, SDK, запись и capture policy не менялись.
|
||||
@@ -0,0 +1,22 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from './ObservationHeader';
|
||||
import type {SensorObservationProps} from './observation';
|
||||
import {useSensorPreview} from './useSensorPreview';
|
||||
import {PointViewport} from './PointViewport';
|
||||
import {useObservationStart} from './useObservationStart';
|
||||
import {startCameraObservation} from './observationStart';
|
||||
|
||||
export function CameraObservation({device,transport,enabled,views}:SensorObservationProps){
|
||||
const video=useRef<HTMLVideoElement>(null);const [generation,setGeneration]=useState(0);
|
||||
const activation=useObservationStart(device,enabled&&device.online&&device.prepared,!['idle','failed'].includes(device.snapshot.acquisition)||!!device.playback_id,()=>startCameraObservation(device,transport));
|
||||
const view=views[0];const {state,telemetry,error,live,hasFrame}=useSensorPreview(device,view.layer,transport,video,enabled,generation);
|
||||
return createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={activation.pending?'Запуск камеры':state}>{activation.error?<IconButton label="Повторить запуск камеры" disabled={activation.pending} onClick={()=>void activation.retry()}><Icon name="refresh"/></IconButton>:error&&<IconButton label="Повторить подключение камеры" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media" data-live={hasFrame}>
|
||||
{view.layer==='points'?<PointViewport points={telemetry.points??[]}/>:view.layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,value])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {value.x.toFixed(3)} · Y {value.y.toFixed(3)} · Z {value.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} autoPlay muted playsInline aria-label={device.name}/>}
|
||||
</div>
|
||||
{!hasFrame&&<p className="observation-source__notice">{activation.error||error|| (!enabled||!device.online?'Нет связи с камерой.':!device.prepared?'Подготовьте камеру в конфигураторе.':activation.pending?'Запуск предпросмотра.':'Ожидание свежих данных источника.')}</p>}
|
||||
</div>,view.target);
|
||||
}
|
||||
@@ -5,18 +5,18 @@ import {PointViewport} from './PointViewport';
|
||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|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);};
|
||||
setTelemetry({});setState('Подключаем просмотр');
|
||||
setTelemetry({});setState('Подключение просмотра');
|
||||
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается этим браузером');return;}
|
||||
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(video.current){video.current.srcObject=new MediaStream([event.track]);if(typeof video.current.requestVideoFrameCallback==='function')frameHandle=video.current.requestVideoFrameCallback(frameSeen);}};
|
||||
pc.onconnectionstatechange=()=>{if(cancelled||terminal)return;if(pc.connectionState==='failed'){terminal=true;setState('Нет связи с камерой');}else if(pc.connectionState==='connected')setState(current=>current==='Прямой эфир'||current==='Исходная запись'?current:'Ожидаем изображение');};
|
||||
pc.onconnectionstatechange=()=>{if(cancelled||terminal)return;if(pc.connectionState==='failed'){terminal=true;setState('Нет связи с камерой');}else if(pc.connectionState==='connected')setState(current=>current==='Прямой эфир'||current==='Исходная запись'?current:'Ожидание изображения');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);if(layer==='points'||layer==='motion')live();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(terminal)return;if(channel.readyState==='open')channel.send('keepalive');if(video.current&&typeof video.current.requestVideoFrameCallback!=='function')void pc.getStats().then(stats=>{stats.forEach(value=>{if(value.type==='inbound-rtp'&&value.kind==='video'&&value.framesDecoded>decoded){decoded=value.framesDecoded;live();}});}).catch(()=>undefined);if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
async function connect(){try{
|
||||
@@ -33,7 +33,7 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
||||
<LoadingRegion loading={state==='Подключаем просмотр'||state==='Ожидаем изображение'} label={state}>
|
||||
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
||||
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
|
||||
</LoadingRegion>
|
||||
<p className="sensor-note">{note??(layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.')}</p>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {StatusBadge} from '@nodedc/ui-react';
|
||||
import type {ObservationHeaderTargets} from './observation';
|
||||
|
||||
/** The source owns liveness and retry actions; the host owns header placement. */
|
||||
export function ObservationHeader({targets,live,label,children}:{targets:ObservationHeaderTargets;live:boolean;label:string;children?:ReactNode}){
|
||||
return <>{createPortal(<StatusBadge variant="indicator" tone={live?'success':'neutral'} role="img" aria-label={label} title={label}/>,targets.statusTarget)}{createPortal(children,targets.actionsTarget)}</>;
|
||||
}
|
||||
@@ -18,13 +18,13 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
||||
<div className="sensor-actions"><Button loading={pendingAction==='preview.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button loading={pendingAction==='record.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={!enabled||pending||(!active&&device.snapshot.acquisition!=='failed')} loading={pendingAction==='stop'} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button></div>
|
||||
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>}
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
|
||||
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
{!detail&&<p>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<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?<LoadingRegion loading label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||
const pending=localBusy.get(item.id)??operation?.action_id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
observation?:import('./observation').SensorObservationContribution;
|
||||
kind:string;
|
||||
Detail:ComponentType<SensorDetailProps>;
|
||||
icon:IconName;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {ComponentType} from 'react';
|
||||
import type {Sensor,SensorTransport} from './contracts';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
|
||||
export interface SensorObservationView {id:string;label:string;layers?:{value:string;label:string}[]}
|
||||
/** Stable host slots let a session update its header without moving its media. */
|
||||
export interface ObservationHeaderTargets {statusTarget:HTMLElement;actionsTarget:HTMLElement}
|
||||
export interface SensorObservationProps {
|
||||
device:Sensor;transport:SensorTransport;enabled:boolean;createRerunHost:RerunHostFactory;
|
||||
views:({id:string;layer:string;target:HTMLElement}&ObservationHeaderTargets)[];
|
||||
}
|
||||
export interface SensorObservationContribution {
|
||||
views:(device:Sensor)=>SensorObservationView[];
|
||||
Session:ComponentType<SensorObservationProps>;
|
||||
}
|
||||
export const sensorLayerLabels:Record<string,string>={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]}));
|
||||
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
||||
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
||||
}
|
||||
export function observationKey(deviceID:string,viewID:string):string {return JSON.stringify([deviceID,viewID]);}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
|
||||
const pending=new Map<string,Promise<unknown>>();
|
||||
export const observationSession=(device:Sensor)=>JSON.stringify([device.snapshot.context.execution.node_id,device.id,device.snapshot.context.session_id]);
|
||||
/** Share an in-flight start/recovery across lens windows and React remounts. */
|
||||
export function observationOperation<T>(device:Sensor,run:()=>Promise<T>):Promise<T>{
|
||||
const key=observationSession(device),current=pending.get(key);if(current)return current as Promise<T>;
|
||||
const task=Promise.resolve().then(run);pending.set(key,task);
|
||||
const clear=()=>{if(pending.get(key)===task)pending.delete(key);};void task.then(clear,clear);return task;
|
||||
}
|
||||
export async function currentObservationDevice(device:Sensor,transport:SensorTransport):Promise<Sensor>{
|
||||
const inventory=await transport.inventory();
|
||||
const current=inventory.items.find(item=>item.id===device.id&&observationSession(item)===observationSession(device));
|
||||
if(inventory.fresh===false||!current?.online||!current.prepared)throw new Error('Нет свежей связи с камерой.');
|
||||
return current;
|
||||
}
|
||||
export function startCameraObservation(device:Sensor,transport:SensorTransport){
|
||||
return observationOperation(device,async()=>{
|
||||
const current=await currentObservationDevice(device,transport);
|
||||
if(current.playback_id||['streaming','starting','stopping'].includes(current.snapshot.acquisition))return;
|
||||
// The sensor owns its published default profiles. Observation never records.
|
||||
await perform(transport,current,'start',{record:false});
|
||||
});
|
||||
}
|
||||
@@ -2,4 +2,6 @@ export * from './contracts';
|
||||
export type * from './enrollment';
|
||||
export type * from './rerunHost';
|
||||
export type * from './extensions';
|
||||
export type * from './observation';
|
||||
export {ObservationHeader} from './ObservationHeader';
|
||||
export * from '../../spatial-ui/src';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Media progress and capture freshness are independent of DOM painting and inventory SSE. */
|
||||
export class PreviewLiveness {
|
||||
private lastFrame:number|null=null;
|
||||
private sourceSeen=Number.NEGATIVE_INFINITY;
|
||||
private staleSince:number|null=null;
|
||||
constructor(private connectedAt:number){}
|
||||
connected(now:number){this.connectedAt=now;}
|
||||
frame(now:number){this.lastFrame=now;}
|
||||
source(fresh:boolean,now:number){
|
||||
this.sourceSeen=now;
|
||||
this.staleSince=fresh?null:this.staleSince??now;
|
||||
}
|
||||
state(now:number):'live'|'waiting'|'delayed'|'source-stalled'|'peer-stalled'{
|
||||
if(this.staleSince!==null&&now-this.sourceSeen<3000){
|
||||
return now-this.staleSince>=12000?'source-stalled':'delayed';
|
||||
}
|
||||
const elapsed=now-(this.lastFrame??this.connectedAt);
|
||||
if(elapsed>8000)return 'peer-stalled';
|
||||
if(this.lastFrame===null)return 'waiting';
|
||||
return elapsed>2000?'delayed':'live';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
type PreviewVideo = Pick<HTMLVideoElement, 'paused' | 'srcObject' | 'muted' | 'play'>;
|
||||
|
||||
/** Restore browser presentation without changing the peer or camera capture. */
|
||||
export function previewPlayback(video: PreviewVideo | null) {
|
||||
let disposed = false, pending = false;
|
||||
return {
|
||||
resume() {
|
||||
if (disposed || pending || !video?.srcObject || !video.paused) return;
|
||||
pending = true;
|
||||
video.muted = true;
|
||||
// WebKit can pause an offscreen panel and reject playback while it remains hidden.
|
||||
// A later visibility/presentation check retries playback on the same stream.
|
||||
void video.play().catch(() => undefined).finally(() => { pending = false; });
|
||||
},
|
||||
dispose() { disposed = true; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import type {Sensor} from './contracts';
|
||||
import {observationSession} from './observationStart';
|
||||
|
||||
/** One automatic activation per mounted view/session; failures require explicit retry. */
|
||||
export function useObservationStart(device:Sensor,enabled:boolean,active:boolean,start:()=>Promise<unknown>){
|
||||
const scope=observationSession(device),latest=useRef({scope,enabled,start});latest.current={scope,enabled,start};
|
||||
const mounted=useRef(false),attempted=useRef(''),running=useRef(false);
|
||||
const [pending,setPending]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
useEffect(()=>{setError('');},[scope]);
|
||||
const retry=useCallback(async()=>{
|
||||
const value=latest.current;if(!value.enabled||running.current)return;
|
||||
attempted.current=value.scope;running.current=true;setPending(true);setError('');
|
||||
try{await value.start();}catch(reason){if(mounted.current&&latest.current.scope===value.scope)setError(reason instanceof Error?reason.message:'Не удалось начать просмотр.');}
|
||||
finally{running.current=false;if(mounted.current)setPending(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
if(!enabled){attempted.current='';return;}
|
||||
if(active){attempted.current=scope;setError('');return;}
|
||||
if(attempted.current!==scope)void retry();
|
||||
},[scope,enabled,active,retry,pending]);
|
||||
return {pending,error,retry};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {useEffect,useRef,useState,type RefObject} from 'react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PreviewLiveness} from './previewLiveness';
|
||||
import {previewPlayback} from './previewPlayback';
|
||||
|
||||
export interface SensorPreviewTelemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;frame_age_ms?:number;fresh?:boolean}
|
||||
/** One viewing peer; capture ownership belongs to the device session. */
|
||||
export function useSensorPreview(device:Sensor,layer:string,transport:SensorTransport,video:RefObject<HTMLVideoElement|null>,enabled=true,generation=0){
|
||||
const [state,setState]=useState('Подключение просмотра');
|
||||
const [telemetry,setTelemetry]=useState<SensorPreviewTelemetry>({});
|
||||
const [error,setError]=useState(''),[live,setLive]=useState(false),[hasFrame,setHasFrame]=useState(false);
|
||||
const [sourceStalled,setSourceStalled]=useState(false),[retryGeneration,setRetryGeneration]=useState(0);const retries=useRef(0);
|
||||
useEffect(()=>{setHasFrame(false);},[device.id,device.snapshot.context.session_id,layer]);
|
||||
useEffect(()=>{retries.current=0;},[device.id,device.snapshot.context.session_id,layer,enabled,generation]);
|
||||
useEffect(()=>{
|
||||
setTelemetry({});setError('');setLive(false);setSourceStalled(false);
|
||||
if(!enabled||!device.online){setState('Нет связи с источником');return;}
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Просмотр остановлен');return;}
|
||||
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается браузером');return;}
|
||||
let cancelled=false,terminal=false,peerID='',closedPeerID='',firstLive=0,statsPending=false;
|
||||
let frameHandle:number|undefined,iceTimer:ReturnType<typeof setTimeout>|undefined,retryTimer:ReturnType<typeof setTimeout>|undefined;
|
||||
const element=video.current,health=new PreviewLiveness(performance.now());
|
||||
const playback=previewPlayback(element);
|
||||
const resumePlayback=()=>{if(!cancelled&&!terminal)playback.resume();};
|
||||
document.addEventListener('visibilitychange',resumePlayback);
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
const data=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
const closePeer=()=>{if(peerID&&closedPeerID!==peerID){closedPeerID=peerID;void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);}};
|
||||
const fail=(reason:string)=>{
|
||||
if(cancelled||terminal)return;
|
||||
terminal=true;setLive(false);pc.close();closePeer();
|
||||
if(retries.current<4){const delay=2000*2**retries.current++;setState('Восстановление просмотра');retryTimer=setTimeout(()=>{if(!cancelled)setRetryGeneration(v=>v+1);},delay);}
|
||||
else{setState('Просмотр недоступен');setError(reason);}
|
||||
};
|
||||
const publish=(checkPeer=false)=>{
|
||||
if(cancelled||terminal)return;
|
||||
const now=performance.now(),value=health.state(now),paused=element?.paused&&document.visibilityState!=='hidden';
|
||||
setLive(value==='live'&&!paused);setSourceStalled(value==='source-stalled'&&document.visibilityState!=='hidden');
|
||||
if(value==='live'&&!paused){firstLive||=now;if(now-firstLive>15000)retries.current=0;setState(device.playback_id?'Исходная запись':'Прямой эфир');}
|
||||
else if(paused&&value==='live'){firstLive=0;setState('Восстановление просмотра');}
|
||||
else{firstLive=0;if(value==='delayed'||value==='source-stalled')setState('Задержка изображения');}
|
||||
if(checkPeer&&value==='peer-stalled'&&document.visibilityState!=='hidden')fail('Не удалось восстановить изображение. Повторите подключение.');
|
||||
};
|
||||
const markFrame=()=>{if(cancelled||terminal)return;health.frame(performance.now());setHasFrame(true);publish();};
|
||||
const frameSeen=()=>{markFrame();if(!cancelled&&!terminal)frameHandle=element?.requestVideoFrameCallback(frameSeen);};
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(element){element.muted=true;element.srcObject=new MediaStream([event.track]);resumePlayback();if(typeof element.requestVideoFrameCallback==='function')frameHandle=element.requestVideoFrameCallback(frameSeen);}};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled&&!terminal&&['failed','closed'].includes(pc.connectionState))fail('Не удалось получить изображение. Повторите подключение.');};
|
||||
data.onmessage=event=>{
|
||||
if(cancelled||terminal)return;
|
||||
try{const value=JSON.parse(event.data);setTelemetry(value);if(typeof value.fresh==='boolean')health.source(value.fresh,performance.now());if(value.fresh!==false&&((layer==='points'&&Array.isArray(value.points))||(layer==='motion'&&value.motion)))markFrame();else publish();}
|
||||
catch{/* Malformed telemetry cannot become a control result. */}
|
||||
};
|
||||
let decoded=0;
|
||||
const timer=setInterval(()=>{
|
||||
if(cancelled||terminal)return;
|
||||
if(data.readyState==='open')data.send('keepalive');
|
||||
resumePlayback();
|
||||
if(!pc.remoteDescription||statsPending)return;
|
||||
// requestVideoFrameCallback can stop for a clipped/hidden decoder or an offscreen panel.
|
||||
// RTP decode progress remains the authority for a functioning video channel.
|
||||
if(element){statsPending=true;void pc.getStats().then(stats=>{if(cancelled||terminal)return;stats.forEach(value=>{if(value.type==='inbound-rtp'&&value.kind==='video'&&value.framesDecoded>decoded){decoded=value.framesDecoded;markFrame();}});publish(true);}).catch(()=>undefined).finally(()=>{statsPending=false;});}
|
||||
else publish(true);
|
||||
},2000);
|
||||
setState('Подключение просмотра');
|
||||
async function connect(){try{
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{iceTimer=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),10000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimer);resolve();}};});
|
||||
if(cancelled)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id;
|
||||
if(cancelled||terminal){closePeer();return;}
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});health.connected(performance.now());
|
||||
}catch(reason){fail(reason instanceof Error?reason.message:'Не удалось открыть просмотр.');}}
|
||||
void connect();
|
||||
return()=>{cancelled=true;playback.dispose();document.removeEventListener('visibilitychange',resumePlayback);clearTimeout(iceTimer);clearTimeout(retryTimer);clearInterval(timer);if(frameHandle!==undefined)element?.cancelVideoFrameCallback(frameHandle);pc.ontrack=null;pc.onconnectionstatechange=null;data.onmessage=null;pc.close();if(element)element.srcObject=null;closePeer();};
|
||||
},[device.id,device.online,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport,video,enabled,generation,retryGeneration]);
|
||||
return {state,telemetry,error,live,hasFrame,sourceStalled};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function RecoverySettings({value,online,enabled,pending,change}:{
|
||||
}) {
|
||||
const failed=['exhausted','unavailable','preview_failed'].includes(value.phase);
|
||||
const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры':
|
||||
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено';
|
||||
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстановление подключения':'Включено';
|
||||
return <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается."
|
||||
actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}>
|
||||
<p>На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.</p>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
|
||||
{available&&status.recording===1&&<p>Запись продолжится после закрытия просмотра.</p>}
|
||||
</SettingsCard>
|
||||
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применяем настройку':'Получаем настройки камеры'}>
|
||||
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применение настройки':'Получение настроек камеры'}>
|
||||
{settings?<CameraSettings value={settings} disabled={!available||busy||status.recording!==0||status.preview!==0} apply={(key,value)=>void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?<p>Настройки недоступны. Обновите состояние камеры.</p>:null}
|
||||
</LoadingRegion>
|
||||
<SettingsCard title="Файлы на камере" actions={<Button disabled={!available||busy} loading={pending==='files.refresh'} onClick={()=>void action('files.list',{offset:0},'files.refresh')}>Обновить список</Button>}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {useEffect,useRef,useState,type RefObject} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {SensorObservationProps} from '../../../../packages/sensor-ui/src/observation';
|
||||
import {useSensorPreview} from '../../../../packages/sensor-ui/src/useSensorPreview';
|
||||
import {useObservationStart} from '../../../../packages/sensor-ui/src/useObservationStart';
|
||||
import {startX4Observation} from './observationStart';
|
||||
import {cameraStatus} from './model';
|
||||
|
||||
function Lens({video,side,label,live}:{video:RefObject<HTMLVideoElement|null>;side:number;label:string;live:boolean}){
|
||||
const canvas=useRef<HTMLCanvasElement>(null);const [supported,setSupported]=useState(true);
|
||||
useEffect(()=>{
|
||||
const source=video.current,target=canvas.current;if(!source||!target)return;
|
||||
let handle:number|undefined,frame:number|undefined,active=true;
|
||||
const draw=()=>{
|
||||
if(!active)return;
|
||||
if(source.readyState>=2&&source.videoWidth){
|
||||
const valid=Math.abs(source.videoWidth/source.videoHeight-2)<.02;setSupported(valid);
|
||||
if(valid){const size=Math.max(1,Math.round(Math.min(target.clientWidth,target.clientHeight)*Math.min(devicePixelRatio,2)));if(target.width!==size||target.height!==size){target.width=size;target.height=size;}target.getContext('2d')?.drawImage(source,side*source.videoWidth/2,0,source.videoWidth/2,source.videoHeight,0,0,size,size);}
|
||||
}
|
||||
};
|
||||
const tick=()=>{draw();if(active)handle=source.requestVideoFrameCallback(tick);};
|
||||
const fallback=()=>{draw();if(active)frame=requestAnimationFrame(fallback);};
|
||||
const resize=new ResizeObserver(draw);resize.observe(target);
|
||||
if(typeof source.requestVideoFrameCallback==='function')handle=source.requestVideoFrameCallback(tick);else frame=requestAnimationFrame(fallback);
|
||||
return()=>{active=false;resize.disconnect();if(handle!==undefined)source.cancelVideoFrameCallback(handle);if(frame!==undefined)cancelAnimationFrame(frame);};
|
||||
},[video,side]);
|
||||
return <><canvas ref={canvas} aria-label={label} className="observation-lens" data-live={live&&supported}/>{!supported&&<p>Текущий формат изображения не содержит пару объективов.</p>}</>;
|
||||
}
|
||||
export function X4Observation({device,transport,enabled,views}:SensorObservationProps){
|
||||
const video=useRef<HTMLVideoElement>(null);const [generation,setGeneration]=useState(0);
|
||||
const status=cameraStatus(device);const available=enabled&&device.online&&status.connected;
|
||||
const activation=useObservationStart(device,available&&device.prepared,status.preview!==0,()=>startX4Observation(device,transport));
|
||||
const {state,error,live,hasFrame,sourceStalled}=useSensorPreview(device,'preview',transport,video,enabled,generation);
|
||||
const [attempts,setAttempts]=useState(0);
|
||||
const recovery=useObservationStart(device,available&&sourceStalled&&status.preview===1&&status.recording===0&&attempts<2,false,async()=>{setAttempts(v=>v+1);await startX4Observation(device,transport,true);setGeneration(v=>v+1);});
|
||||
useEffect(()=>{if(!live)return;const timer=setTimeout(()=>setAttempts(0),30000);return()=>clearTimeout(timer);},[live]);
|
||||
useEffect(()=>{setAttempts(0);},[device.id,device.snapshot.context.session_id]);
|
||||
const pending=activation.pending||recovery.pending;
|
||||
const failure=!live&&(activation.error||recovery.error||(sourceStalled&&attempts>=2&&!pending?'Не удалось восстановить изображение. Повторите просмотр.':''))||'';
|
||||
return <><video ref={video} autoPlay muted playsInline aria-hidden="true" className="observation-decoder"/>{views.map(view=>createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={pending?recovery.pending?'Восстановление изображения':'Запуск камеры':state}>{failure&&<IconButton label="Повторить просмотр X4" disabled={pending||!available} onClick={()=>{if(status.preview===0)void activation.retry();else{setAttempts(0);void recovery.retry();}}}><Icon name="refresh"/></IconButton>}{error&&!failure&&<IconButton label="Повторить подключение X4" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media"><Lens video={video} side={view.id==='left'?0:1} label={`${device.name} ${view.id==='left'?'Left':'Right'} Cam`} live={hasFrame}/></div>
|
||||
{!hasFrame&&<p className="observation-source__notice">{failure||error||(!available?'Нет свежей связи с камерой.':pending?'Подключение обоих объективов.':sourceStalled&&status.recording!==0?'Камера перестала передавать кадры. Восстановление доступно после завершения записи.':'Ожидание изображения.')}</p>}
|
||||
</div>,view.target,view.id))}</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {currentObservationDevice,observationOperation} from '../../../../packages/sensor-ui/src/observationStart';
|
||||
import {cameraStatus} from './model';
|
||||
|
||||
export function startX4Observation(device:Sensor,transport:SensorTransport,restart=false){
|
||||
return observationOperation(device,async()=>{
|
||||
let current=await currentObservationDevice(device,transport);let status=cameraStatus(current);
|
||||
if(!status.connected)throw new Error('Камера не подключена.');
|
||||
if(status.preview===1&&!restart)return;
|
||||
if(restart){
|
||||
if(status.recording!==0)throw new Error('Не удалось восстановить просмотр во время записи.');
|
||||
if(status.preview===1)await perform(transport,current,'preview.stop');
|
||||
current=await currentObservationDevice(device,transport);status=cameraStatus(current);
|
||||
if(status.recording!==0)throw new Error('Состояние записи изменилось. Повторите просмотр после её завершения.');
|
||||
}else if(status.preview!==0)throw new Error('Состояние просмотра пока не подтверждено.');
|
||||
await perform(transport,current,'preview.start');
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {X4Detail} from './X4Detail';
|
||||
import {cameraLabel,cameraRowActions} from './model';
|
||||
import {X4Observation} from './X4Observation';
|
||||
|
||||
export const insta360X4SensorUi:SensorUiContribution={
|
||||
kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true,
|
||||
supportsPreparation:true,supportsRenaming:true,status:cameraLabel,
|
||||
rowActions:cameraRowActions,
|
||||
observation:{Session:X4Observation,views:device=>[{id:'left',label:`${device.name} Left Cam`},{id:'right',label:`${device.name} Right Cam`}]},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {observationRerun} from './observationRerun';
|
||||
import {useCallback,useEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader,type SensorObservationProps} from '@mission-core/sensor-sdk';
|
||||
import {useK1Preview,pendingPreview,type PreviewStatus} from './useK1Preview';
|
||||
|
||||
function ActiveK1Observation({device,transport,enabled,views,createRerunHost}:SensorObservationProps){
|
||||
const spatial=useRef<HTMLDivElement>(null),plan=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [status,setStatus]=useState(pendingPreview),[generation,setGeneration]=useState(0);const attempts=useRef(0);
|
||||
const report=useCallback((value:PreviewStatus)=>setStatus(value),[]);
|
||||
const host=useMemo(()=>observationRerun(createRerunHost,plan),[createRerunHost]);
|
||||
useK1Preview(device,transport,host,spatial,video,report,generation,enabled);
|
||||
useEffect(()=>{if(!status.retry)return;const timer=setTimeout(()=>setGeneration(v=>v+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));return()=>clearTimeout(timer);},[status.retry]);
|
||||
return <>{views.map(view=>{const points=view.id!=='camera',live=points?status.presented:status.cameraPresented;return createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={live?'Прямой эфир':points?status.lidar:status.camera}>{status.retry&&<IconButton label="Повторить подключение K1" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media">{points?<div className="observation-rerun" ref={view.id==='plan'?plan:spatial}/>:<video ref={video} autoPlay muted playsInline aria-label={`${device.name} Cam Right`} data-live={live}/>}</div>
|
||||
</div>,view.target,view.id);})}</>;
|
||||
}
|
||||
export function K1Observation(props:SensorObservationProps){
|
||||
if(!props.enabled||props.device.snapshot.acquisition!=='streaming')return <>{props.views.map(view=>createPortal(<div className="observation-source"><ObservationHeader targets={view} live={false} label={props.enabled?'Просмотр остановлен':'Нет связи с источником'}/><p className="observation-source__notice">Начните приём данных в конфигураторе K1.</p></div>,view.target,view.id))}</>;
|
||||
return <ActiveK1Observation {...props}/>;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
import type {RefObject} from 'react';
|
||||
import type {RerunHostFactory,LiveRerunViewer} from '@mission-core/sensor-sdk';
|
||||
|
||||
/** One incoming RRD stream, two independently navigable native views. */
|
||||
export function observationRerun(factory:RerunHostFactory,plan:RefObject<HTMLDivElement|null>):RerunHostFactory {
|
||||
return element=>{
|
||||
const spatial=factory(element),overhead=factory(plan.current!);let disposed=false;
|
||||
const controller=new AbortController();
|
||||
const load=async(url:URL)=>{const response=await fetch(url,{signal:controller.signal});if(!response.ok)throw new Error('Не удалось загрузить вид облака точек.');return new Uint8Array(await response.arrayBuffer());};
|
||||
const ready=Promise.all([spatial.ready,overhead.ready,load(new URL('./observation-3d.rbl',import.meta.url)),load(new URL('./observation-plan.rbl',import.meta.url))]).then(([a,b,perspective,top])=>{
|
||||
let primaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,secondaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,recording:string|null=null;
|
||||
const viewer:LiveRerunViewer={
|
||||
...a.viewer,
|
||||
start:async(source,mount,options)=>{await a.viewer.start(source,mount,options);if(disposed)return;await b.viewer.start(source,b.mount,options);primaryBlueprint=a.viewer.open_channel('observation-perspective');secondaryBlueprint=b.viewer.open_channel('observation-plan');},
|
||||
open_channel:name=>{const first=a.viewer.open_channel(name),second=b.viewer.open_channel(name);return {get ready(){return first.ready&&second.ready;},send_rrd:bytes=>{second.send_rrd(bytes.slice());first.send_rrd(bytes);},close:()=>{first.close();second.close();}};},
|
||||
get_active_recording_id:()=>{const id=a.viewer.get_active_recording_id();if(id&&id!==recording&&primaryBlueprint?.ready&&secondaryBlueprint?.ready&&b.viewer.get_active_recording_id()===id){primaryBlueprint.send_rrd(perspective.slice());secondaryBlueprint.send_rrd(top.slice());recording=id;}return id;},
|
||||
override_panel_state:(panel,state)=>{a.viewer.override_panel_state(panel,state);b.viewer.override_panel_state(panel,state);},
|
||||
set_active_timeline:(id,timeline)=>{a.viewer.set_active_timeline(id,timeline);b.viewer.set_active_timeline(id,timeline);},
|
||||
};
|
||||
return {viewer,mount:a.mount};
|
||||
});
|
||||
return {ready,dispose:()=>{disposed=true;controller.abort();spatial.dispose();overhead.dispose();}};
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||
import {K1Detail} from './K1Detail';
|
||||
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
import {k1Status} from './presentation';
|
||||
import {K1Observation} from './K1Observation';
|
||||
|
||||
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`}]},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Build K1 observation layouts with the repository's pinned Rerun SDK.
|
||||
|
||||
These assets contain renderer configuration only, never captured device data.
|
||||
Run with .venv/bin/python scripts/build_observation_blueprints.py.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
from k1link.viewer.rerun_bridge import _live_time_panel
|
||||
|
||||
destination = Path(__file__).resolve().parents[1] / "plugins/xgrids-k1/frontend/src/sensors"
|
||||
for mode, position, up in (
|
||||
("3d", [16.0, -16.0, 18.0], [0.0, 0.0, 1.0]),
|
||||
("plan", [0.0, 0.0, 30.0], [0.0, 1.0, 0.0]),
|
||||
):
|
||||
view = rrb.Spatial3DView(
|
||||
origin="/world", contents=["/world/**"], name="K1 · " + mode,
|
||||
eye_controls=rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital, position=position,
|
||||
look_target=[0.0, 0.0, 0.0], eye_up=up,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
),
|
||||
time_ranges=[rr.VisibleTimeRange("stream_time", start=rr.TimeRangeBoundary.cursor_relative(seconds=-12), end=rr.TimeRangeBoundary.cursor_relative())],
|
||||
)
|
||||
rrb.Blueprint(view, _live_time_panel(), auto_layout=False, auto_views=False, collapse_panels=True).save(
|
||||
"nodedc_mission_core_spatial", destination / f"observation-{mode}.rbl"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan/apply a loopback-only SSH link to the existing NAS Map Gateway.
|
||||
|
||||
No remote command, NAS configuration, provider credential or cache is changed.
|
||||
The user LaunchAgent reconnects independently of the Core process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LABEL = "com.nodedc.mission-core.map-gateway-link"
|
||||
PORT = 18103
|
||||
|
||||
|
||||
def payload(target: str, runtime: Path) -> bytes:
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_][a-zA-Z0-9_.-]*@[a-zA-Z0-9][a-zA-Z0-9.-]*", target):
|
||||
raise ValueError("SSH target must be an explicit user@host")
|
||||
return plistlib.dumps({
|
||||
"Label": LABEL,
|
||||
"ProgramArguments": [
|
||||
"/usr/bin/ssh", "-N", "-T", "-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3", "-o", "PermitLocalCommand=no",
|
||||
"-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
"-L", f"127.0.0.1:{PORT}:127.0.0.1:{PORT}", target,
|
||||
],
|
||||
"RunAtLoad": True, "KeepAlive": True, "ThrottleInterval": 30,
|
||||
"ExitTimeOut": 10,
|
||||
"StandardOutPath": str(runtime / "stdout.log"),
|
||||
"StandardErrorPath": str(runtime / "stderr.log"),
|
||||
}, sort_keys=True)
|
||||
|
||||
|
||||
def digest(value: bytes | None) -> str:
|
||||
return hashlib.sha256(value).hexdigest() if value is not None else "absent"
|
||||
|
||||
|
||||
def health() -> bool:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", PORT, timeout=2)
|
||||
try:
|
||||
connection.request("GET", "/healthz", headers={
|
||||
"x-nodedc-user-id": "mission-core-loopback-operator",
|
||||
})
|
||||
response = connection.getresponse()
|
||||
raw = response.read(65537)
|
||||
if response.status != 200 or len(raw) > 65536:
|
||||
return False
|
||||
value = json.loads(raw)
|
||||
return value.get("ok") is True and value.get("service") == "nodedc-map-gateway"
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
return False
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def loaded() -> bool:
|
||||
return subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"],
|
||||
capture_output=True, timeout=5, check=False).returncode == 0
|
||||
|
||||
|
||||
def unload() -> None:
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"],
|
||||
capture_output=True, timeout=20, check=False)
|
||||
deadline = time.monotonic() + 20
|
||||
while loaded():
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Map Gateway link did not finish unloading")
|
||||
time.sleep(.2)
|
||||
|
||||
|
||||
def write_atomic(path: Path, value: bytes) -> None:
|
||||
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".map-link-")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(value)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["plan", "apply", "status"])
|
||||
parser.add_argument("--target")
|
||||
parser.add_argument("--expected-current-sha256")
|
||||
parser.add_argument("--expected-desired-sha256")
|
||||
args = parser.parse_args()
|
||||
if args.action == "status":
|
||||
print(json.dumps({"loaded": loaded(), "healthy": health()}))
|
||||
return
|
||||
if not args.target:
|
||||
parser.error("--target is required")
|
||||
runtime = Path.home() / "Library/Logs/NODEDC/MissionCore/map-gateway-link"
|
||||
agent = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
||||
if agent.is_symlink():
|
||||
raise ValueError("Refusing a symlink LaunchAgent")
|
||||
previous = agent.read_bytes() if agent.exists() else None
|
||||
if previous is not None and plistlib.loads(previous).get("Label") != LABEL:
|
||||
raise ValueError("Refusing a foreign LaunchAgent")
|
||||
desired = payload(args.target, runtime)
|
||||
plan = {"label": LABEL, "agent_path": str(agent), "target": args.target,
|
||||
"local_bind": f"127.0.0.1:{PORT}", "remote_bind": f"127.0.0.1:{PORT}",
|
||||
"current_sha256": digest(previous), "desired_sha256": digest(desired),
|
||||
"remote_mutation": False}
|
||||
if args.action == "plan":
|
||||
print(json.dumps(plan, indent=2))
|
||||
return
|
||||
if (args.expected_current_sha256 != digest(previous)
|
||||
or args.expected_desired_sha256 != digest(desired)):
|
||||
raise ValueError("Map Gateway link plan changed before apply")
|
||||
was_loaded = loaded()
|
||||
if previous == desired and was_loaded and health():
|
||||
print(json.dumps({**plan, "applied": False, "healthy": True}))
|
||||
return
|
||||
if not was_loaded:
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", PORT)) # Refuse an unrelated local listener.
|
||||
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
agent.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if previous is not None:
|
||||
write_atomic(runtime / f"backup-{digest(previous)}.plist", previous)
|
||||
try:
|
||||
if was_loaded:
|
||||
unload()
|
||||
write_atomic(agent, desired)
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, timeout=20, check=True)
|
||||
deadline = time.monotonic() + 40
|
||||
while not health():
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Map Gateway link did not pass health acceptance")
|
||||
time.sleep(.5)
|
||||
except BaseException:
|
||||
unload()
|
||||
if previous is not None:
|
||||
write_atomic(agent, previous)
|
||||
if was_loaded:
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, timeout=20, check=True)
|
||||
else:
|
||||
agent.unlink(missing_ok=True)
|
||||
raise
|
||||
print(json.dumps({**plan, "applied": True, "healthy": True}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,6 +33,8 @@ ACTIONS = {
|
||||
}
|
||||
MAX_INVENTORY_ITEMS = 500
|
||||
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
||||
MAX_PENDING_COMMANDS = 32
|
||||
MAX_COMMAND_RECEIPTS = 128
|
||||
|
||||
|
||||
def validate_inventory(value, node_id):
|
||||
@@ -94,8 +96,29 @@ def submit(fleet, vehicle_id, value):
|
||||
row["sensor_commands"] = {
|
||||
k: v for k, v in row["sensor_commands"].items() if time.time() - v["updated_at"] < 600
|
||||
}
|
||||
if len(row["sensor_commands"]) >= 32:
|
||||
now = datetime.now(UTC)
|
||||
# Completed viewing operations are receipts, not occupied execution
|
||||
# slots. Keep them for idempotency without blocking a later Stop.
|
||||
active = sum(
|
||||
entry["state"] in ("queued", "running")
|
||||
and datetime.fromisoformat(entry["command"]["deadline_at"]) > now
|
||||
for entry in row["sensor_commands"].values()
|
||||
)
|
||||
if active >= MAX_PENDING_COMMANDS:
|
||||
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
|
||||
if len(row["sensor_commands"]) >= MAX_COMMAND_RECEIPTS:
|
||||
expired = sorted(
|
||||
(k for k, entry in row["sensor_commands"].items()
|
||||
if entry["state"] not in ("queued", "running")
|
||||
and datetime.fromisoformat(entry["command"]["deadline_at"]) < now),
|
||||
key=lambda k: row["sensor_commands"][k]["updated_at"],
|
||||
)
|
||||
for key in expired:
|
||||
del row["sensor_commands"][key]
|
||||
if len(row["sensor_commands"]) < MAX_COMMAND_RECEIPTS:
|
||||
break
|
||||
if len(row["sensor_commands"]) >= MAX_COMMAND_RECEIPTS:
|
||||
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
|
||||
operation = {"command": value, "state": "queued", "updated_at": time.time()}
|
||||
row["sensor_commands"][command.operation_id] = operation
|
||||
fleet.save(row)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import copy
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.fleet import sensors
|
||||
from k1link.fleet.trust import PairingError
|
||||
|
||||
|
||||
class Fleet:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.row = {"sensor_state": {"items": [{"id": "synthetic-camera", "snapshot": {"context": {"session_id": "synthetic-session"}}}]}}
|
||||
|
||||
def find(self, _):
|
||||
return self.row
|
||||
|
||||
def public(self, _):
|
||||
return {"connectivity": "online"}
|
||||
|
||||
def save(self, _):
|
||||
pass
|
||||
|
||||
|
||||
def request():
|
||||
now = datetime.now(UTC)
|
||||
identifier = "op_" + uuid4().hex
|
||||
return {"api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", "kind": "OperationRequest", "operation_id": identifier, "idempotency_key": identifier, "session": {"device_id": "synthetic-camera", "session_id": "synthetic-session"}, "requested_at": now.isoformat(), "deadline_at": (now + timedelta(seconds=60)).isoformat(), "action_id": "preview.stop", "parameters": {}}
|
||||
|
||||
|
||||
def test_completed_viewing_receipts_do_not_block_stop_and_remain_idempotent():
|
||||
fleet = Fleet()
|
||||
for _ in range(32):
|
||||
value = request()
|
||||
receipt = sensors.submit(fleet, "vehicle", value)
|
||||
receipt["state"] = "complete"
|
||||
assert sensors.submit(fleet, "vehicle", value) is receipt
|
||||
assert sensors.submit(fleet, "vehicle", request())["state"] == "queued"
|
||||
|
||||
|
||||
def test_pending_commands_still_have_the_original_32_slot_limit():
|
||||
fleet = Fleet()
|
||||
for _ in range(32):
|
||||
sensors.submit(fleet, "vehicle", request())
|
||||
with pytest.raises(PairingError, match="Слишком много"):
|
||||
sensors.submit(fleet, "vehicle", request())
|
||||
|
||||
|
||||
def test_receipt_storage_is_bounded_and_only_expired_terminal_receipts_are_reclaimable():
|
||||
fleet = Fleet()
|
||||
for _ in range(sensors.MAX_COMMAND_RECEIPTS):
|
||||
value = request()
|
||||
receipt = sensors.submit(fleet, "vehicle", value)
|
||||
receipt["state"] = "complete"
|
||||
with pytest.raises(PairingError, match="Слишком много"):
|
||||
sensors.submit(fleet, "vehicle", request())
|
||||
expired = next(iter(fleet.row["sensor_commands"].values()))
|
||||
expired["command"]["requested_at"] = (datetime.now(UTC) - timedelta(seconds=61)).isoformat()
|
||||
expired["command"]["deadline_at"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
|
||||
expired["updated_at"] = time.time() - 60
|
||||
replay = copy.deepcopy(expired["command"])
|
||||
sensors.submit(fleet, "vehicle", request())
|
||||
assert len(fleet.row["sensor_commands"]) == sensors.MAX_COMMAND_RECEIPTS
|
||||
with pytest.raises(PairingError, match="Срок команды"):
|
||||
sensors.submit(fleet, "vehicle", replay)
|
||||
@@ -0,0 +1,60 @@
|
||||
import importlib.util
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts/manage_map_gateway_link.py"
|
||||
spec = importlib.util.spec_from_file_location("map_gateway_link", SCRIPT)
|
||||
assert spec and spec.loader
|
||||
link = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(link)
|
||||
|
||||
|
||||
def test_link_is_local_only_and_cannot_execute_remote_commands(tmp_path):
|
||||
value = plistlib.loads(link.payload("operator@nas.example", tmp_path))
|
||||
argv = value["ProgramArguments"]
|
||||
assert argv[-3:] == ["-L", "127.0.0.1:18103:127.0.0.1:18103", "operator@nas.example"]
|
||||
for flag in ("-N", "-T", "StrictHostKeyChecking=yes", "BatchMode=yes",
|
||||
"ExitOnForwardFailure=yes", "ForwardAgent=no", "PermitLocalCommand=no"):
|
||||
assert flag in argv
|
||||
assert "-R" not in argv and "-D" not in argv
|
||||
assert value["KeepAlive"] and value["ThrottleInterval"] == 30
|
||||
for target in ("-oProxyCommand=evil", "operator@host;touch x", "operator@host\ncommand"):
|
||||
with pytest.raises(ValueError):
|
||||
link.payload(target, tmp_path)
|
||||
|
||||
|
||||
def test_stale_plan_cannot_change_runtime(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(link.Path, "home", lambda: tmp_path)
|
||||
run = Mock()
|
||||
monkeypatch.setattr(link.subprocess, "run", run)
|
||||
monkeypatch.setattr("sys.argv", [str(SCRIPT), "apply", "--target", "operator@nas.example",
|
||||
"--expected-current-sha256", "absent",
|
||||
"--expected-desired-sha256", "stale"])
|
||||
with pytest.raises(ValueError, match="plan changed"):
|
||||
link.main()
|
||||
run.assert_not_called()
|
||||
assert not (tmp_path / "Library").exists()
|
||||
|
||||
|
||||
def test_failed_first_install_removes_candidate_agent(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(link.Path, "home", lambda: tmp_path)
|
||||
runtime = tmp_path / "Library/Logs/NODEDC/MissionCore/map-gateway-link"
|
||||
expected = link.digest(link.payload("operator@nas.example", runtime))
|
||||
monkeypatch.setattr("sys.argv", [str(SCRIPT), "apply", "--target", "operator@nas.example",
|
||||
"--expected-current-sha256", "absent",
|
||||
"--expected-desired-sha256", expected])
|
||||
monkeypatch.setattr(link, "loaded", lambda: False)
|
||||
unload = Mock()
|
||||
monkeypatch.setattr(link, "unload", unload)
|
||||
socket = Mock()
|
||||
socket.__enter__ = Mock(return_value=Mock())
|
||||
socket.__exit__ = Mock(return_value=False)
|
||||
monkeypatch.setattr(link.socket, "socket", lambda: socket)
|
||||
monkeypatch.setattr(link.subprocess, "run", Mock(side_effect=RuntimeError("bootstrap failed")))
|
||||
with pytest.raises(RuntimeError, match="bootstrap failed"):
|
||||
link.main()
|
||||
unload.assert_called_once()
|
||||
assert not (tmp_path / "Library/LaunchAgents" / f"{link.LABEL}.plist").exists()
|
||||
Reference in New Issue
Block a user