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:
DCCONSTRUCTIONS
2026-09-10 22:12:05 +03:00
parent 5f75afb720
commit be58d589e2
48 changed files with 1575 additions and 83 deletions
@@ -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>} {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>} {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> </SettingsCard>
{!value?<ActivityIndicator label="Получаем мониторинг БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<> {!value?<ActivityIndicator label="Получение мониторинга БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
<SettingsCard title="История нагрузки"> <SettingsCard title="История нагрузки">
<div className="board-monitor-controls"> <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]);}}/> <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> <p>{time(value.start)} {time(value.end)} · интервал {value.bucket_seconds} с. Разрывы означают отсутствие измерений.</p>
{!value.series.length&&<p>За выбранный период измерений нет.</p>} {!value.series.length&&<p>За выбранный период измерений нет.</p>}
</SettingsCard> </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> <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>
<SettingsCard title="События системы и приложения" description="За выбранный период"> <SettingsCard title="События системы и приложения" description="За выбранный период">
@@ -2,21 +2,10 @@ import {useMemo} from 'react';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost'; import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost'; import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace'; import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts'; import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){ export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
const {registry}=useDevicePluginHost(); const {registry}=useDevicePluginHost();
const sensorContributions=registry.sensorContributions; const sensorContributions=registry.sensorContributions;
const transport=useMemo<SensorTransport>(()=>({ const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
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]);
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>; 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 {BoardMonitorView} from './BoardMonitorView';
import { useEffect, useRef, useState } from "react"; 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"; 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 [selected, setSelected] = useState<string | null>(null);
const [sensorOpen, setSensorOpen] = useState(false); const [sensorOpen, setSensorOpen] = useState(false);
const [monitorOpen,setMonitorOpen]=useState(false); const [monitorOpen,setMonitorOpen]=useState(false);
const [observationOpen,setObservationOpen]=useState(false);
const [revoking, setRevoking] = useState<Vehicle | null>(null); const [revoking, setRevoking] = useState<Vehicle | null>(null);
const lastCreateRequest = useRef(createRequest); const lastCreateRequest = useRef(createRequest);
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [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); } finally { setPending(false); }
} }
const detail = fleet.items?.find(item => item.id === selected); 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)}/>; if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
return <div className="fleet-workspace"> return <div className="fleet-workspace">
{fleet.error && <p role="alert">{fleet.error}</p>} {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> <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></>} {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> </dl>
<Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button> <div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>} <Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
</SettingsCard>} </SettingsCard>}
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></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>} </> : !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>}> <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}> <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="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} 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 { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
.fleet-facts dt { color: var(--nodedc-text-secondary); } .fleet-facts dt { color: var(--nodedc-text-secondary); }
.fleet-facts dd { color: var(--nodedc-text-muted); } .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 { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -207,60 +208,7 @@ export function WorldMapWorkspace({
runtimeConfigUrl="/api/v1/map/runtime-config" runtimeConfigUrl="/api/v1/map/runtime-config"
camera={draft.view.camera} camera={draft.view.camera}
layers={draft.view.layerVisibility} layers={draft.view.layerVisibility}
settings={{ settings={mapPresentation(draft.view.visualSettings)}
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,
}}
cacheIntent={{ cacheIntent={{
enabled: draft.view.cacheIntent.enabled, enabled: draft.view.cacheIntent.enabled,
no_overwrite: draft.view.cacheIntent.noOverwrite, 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);
});
+3
View File
@@ -35,6 +35,9 @@
], ],
"@mission-core/sensor-sdk": [ "@mission-core/sensor-sdk": [
"../../packages/sensor-ui/src/pluginSdk.ts" "../../packages/sensor-ui/src/pluginSdk.ts"
],
"react-dom": [
"node_modules/@types/react-dom/index.d.ts"
] ]
}, },
"resolveJsonModule": true, "resolveJsonModule": true,
+3
View File
@@ -28,6 +28,9 @@
], ],
"@mission-core/sensor-sdk": [ "@mission-core/sensor-sdk": [
"../../../packages/sensor-ui/src/pluginSdk.ts" "../../../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. D455 streaming; X4 idle/preview0/recording0/recoveryfalse/preview_wantedfalse.
Owner informed installation completed and no further password needed; only Owner informed installation completed and no further password needed; only
Power-off with USB retained is awaited for MANUAL02. No new wake issued yet. 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.
+311
View File
@@ -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);
}
+4 -4
View File
@@ -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}} 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}){ 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 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(()=>{ useEffect(()=>{
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;} 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; 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 live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);}; const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
setTelemetry({});setState('Подключаем просмотр'); setTelemetry({});setState('Подключение просмотра');
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается этим браузером');return;} if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается этим браузером');return;}
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0}); const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'}); 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.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. */ }}; 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); 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{ async function connect(){try{
@@ -33,7 +33,7 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
const active=device.snapshot.acquisition==='streaming'; 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></>}> 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>:<> {!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/>} {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> </LoadingRegion>
<p className="sensor-note">{note??(layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.')}</p> <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)}</>;
}
+2 -2
View File
@@ -18,13 +18,13 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
{device.snapshot.message&&<p>{device.snapshot.message}</p>} {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> <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.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>} {device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>} {device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
</SettingsCard> </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}/> {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="Профили потоков" 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="Параметры камеры"><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></>} <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>; </div>;
} }
+1 -1
View File
@@ -46,7 +46,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null; 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>:<> 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> <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 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 pending=localBusy.get(item.id)??operation?.action_id;
const configured=item.configured??item.snapshot.enrollment==='enrolled'; const configured=item.configured??item.snapshot.enrollment==='enrolled';
+1
View File
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode; renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
} }
export interface SensorUiContribution { export interface SensorUiContribution {
observation?:import('./observation').SensorObservationContribution;
kind:string; kind:string;
Detail:ComponentType<SensorDetailProps>; Detail:ComponentType<SensorDetailProps>;
icon:IconName; icon:IconName;
+22
View File
@@ -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
View File
@@ -2,4 +2,6 @@ export * from './contracts';
export type * from './enrollment'; export type * from './enrollment';
export type * from './rerunHost'; export type * from './rerunHost';
export type * from './extensions'; export type * from './extensions';
export type * from './observation';
export {ObservationHeader} from './ObservationHeader';
export * from '../../spatial-ui/src'; export * from '../../spatial-ui/src';
+22
View File
@@ -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';
}
}
+17
View File
@@ -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 failed=['exhausted','unavailable','preview_failed'].includes(value.phase);
const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры': const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры':
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено'; ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстановление подключения':'Включено';
return <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается." return <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается."
actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}> actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}>
<p>На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.</p> <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> <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>} {available&&status.recording===1&&<p>Запись продолжится после закрытия просмотра.</p>}
</SettingsCard> </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} {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> </LoadingRegion>
<SettingsCard title="Файлы на камере" actions={<Button disabled={!available||busy} loading={pending==='files.refresh'} onClick={()=>void action('files.list',{offset:0},'files.refresh')}>Обновить список</Button>}> <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 type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
import {X4Detail} from './X4Detail'; import {X4Detail} from './X4Detail';
import {cameraLabel,cameraRowActions} from './model'; import {cameraLabel,cameraRowActions} from './model';
import {X4Observation} from './X4Observation';
export const insta360X4SensorUi:SensorUiContribution={ export const insta360X4SensorUi:SensorUiContribution={
kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true, kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true,
supportsPreparation:true,supportsRenaming:true,status:cameraLabel, supportsPreparation:true,supportsRenaming:true,status:cameraLabel,
rowActions:cameraRowActions, 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}/>;
}
@@ -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 {K1Detail} from './K1Detail';
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow'; import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
import {k1Status} from './presentation'; import {k1Status} from './presentation';
import {K1Observation} from './K1Observation';
export const xgridsK1SensorUi:SensorUiContribution={ export const xgridsK1SensorUi:SensorUiContribution={
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false, kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
supportsRenaming:false,status:k1Status, supportsRenaming:false,status:k1Status,
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow}, 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`}]},
}; };
+27
View File
@@ -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"
)
+161
View File
@@ -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()
+24 -1
View File
@@ -33,6 +33,8 @@ ACTIONS = {
} }
MAX_INVENTORY_ITEMS = 500 MAX_INVENTORY_ITEMS = 500
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024 MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
MAX_PENDING_COMMANDS = 32
MAX_COMMAND_RECEIPTS = 128
def validate_inventory(value, node_id): def validate_inventory(value, node_id):
@@ -94,8 +96,29 @@ def submit(fleet, vehicle_id, value):
row["sensor_commands"] = { row["sensor_commands"] = {
k: v for k, v in row["sensor_commands"].items() if time.time() - v["updated_at"] < 600 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("Слишком много запросов к устройству. Повторите позже.") 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()} operation = {"command": value, "state": "queued", "updated_at": time.time()}
row["sensor_commands"][command.operation_id] = operation row["sensor_commands"][command.operation_id] = operation
fleet.save(row) fleet.save(row)
+68
View File
@@ -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)
+60
View File
@@ -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()