From be58d589e2fba520b1a3800574073f01a7fc9591 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 10 Sep 2026 22:12:05 +0300 Subject: [PATCH] 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. --- .../src/components/map/mapPresentation.ts | 59 ++++ .../src/core/fleet/observationLayout.ts | 23 ++ .../src/core/fleet/sensorTransport.ts | 18 + .../src/workspaces/fleet/BoardMonitorView.tsx | 4 +- .../src/workspaces/fleet/VehicleSensors.tsx | 15 +- .../workspaces/fleet/VehiclesWorkspace.tsx | 12 +- .../src/workspaces/fleet/fleet.css | 4 + .../observation/BoardObservationCenter.tsx | 73 ++++ .../fleet/observation/ObservationDeck.tsx | 16 + .../fleet/observation/ObservationMap.tsx | 26 ++ .../fleet/observation/observation.css | 45 +++ .../observation/useObservationInventory.ts | 17 + .../src/workspaces/map/WorldMapWorkspace.tsx | 56 +--- .../test/boardObservation.test.mjs | 43 +++ .../test/observationActivation.test.mjs | 52 +++ .../test/previewLiveness.test.mjs | 52 +++ apps/control-station/tsconfig.app.json | 3 + apps/node-agent/ui/tsconfig.json | 3 + .../07_INSTA360_X4_INSTALLATION_LEDGER.md | 130 ++++++++ docs/node/08_BOARD_OBSERVATION_CENTER.md | 311 ++++++++++++++++++ packages/sensor-ui/src/CameraObservation.tsx | 22 ++ packages/sensor-ui/src/LiveViewport.tsx | 8 +- packages/sensor-ui/src/ObservationHeader.tsx | 9 + packages/sensor-ui/src/SensorDetail.tsx | 4 +- packages/sensor-ui/src/SensorWorkspace.tsx | 2 +- packages/sensor-ui/src/extensions.ts | 1 + packages/sensor-ui/src/observation.ts | 22 ++ packages/sensor-ui/src/observationStart.ts | 24 ++ packages/sensor-ui/src/pluginSdk.ts | 2 + packages/sensor-ui/src/previewLiveness.ts | 22 ++ packages/sensor-ui/src/previewPlayback.ts | 17 + packages/sensor-ui/src/useObservationStart.ts | 24 ++ packages/sensor-ui/src/useSensorPreview.ts | 78 +++++ .../frontend/src/RecoverySettings.tsx | 2 +- plugins/insta360-x4/frontend/src/X4Detail.tsx | 2 +- .../frontend/src/X4Observation.tsx | 47 +++ .../frontend/src/observationStart.ts | 18 + plugins/insta360-x4/frontend/src/plugin.ts | 2 + .../frontend/src/sensors/K1Observation.tsx | 23 ++ .../frontend/src/sensors/observation-3d.rbl | Bin 0 -> 41265 bytes .../frontend/src/sensors/observation-plan.rbl | Bin 0 -> 41298 bytes .../frontend/src/sensors/observationRerun.ts | 24 ++ .../xgrids-k1/frontend/src/sensors/plugin.ts | 2 + scripts/build_observation_blueprints.py | 27 ++ scripts/manage_map_gateway_link.py | 161 +++++++++ src/k1link/fleet/sensors.py | 25 +- tests/fleet/test_sensor_queue.py | 68 ++++ tests/test_map_gateway_link.py | 60 ++++ 48 files changed, 1575 insertions(+), 83 deletions(-) create mode 100644 apps/control-station/src/components/map/mapPresentation.ts create mode 100644 apps/control-station/src/core/fleet/observationLayout.ts create mode 100644 apps/control-station/src/core/fleet/sensorTransport.ts create mode 100644 apps/control-station/src/workspaces/fleet/observation/BoardObservationCenter.tsx create mode 100644 apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx create mode 100644 apps/control-station/src/workspaces/fleet/observation/ObservationMap.tsx create mode 100644 apps/control-station/src/workspaces/fleet/observation/observation.css create mode 100644 apps/control-station/src/workspaces/fleet/observation/useObservationInventory.ts create mode 100644 apps/control-station/test/boardObservation.test.mjs create mode 100644 apps/control-station/test/observationActivation.test.mjs create mode 100644 apps/control-station/test/previewLiveness.test.mjs create mode 100644 docs/node/08_BOARD_OBSERVATION_CENTER.md create mode 100644 packages/sensor-ui/src/CameraObservation.tsx create mode 100644 packages/sensor-ui/src/ObservationHeader.tsx create mode 100644 packages/sensor-ui/src/observation.ts create mode 100644 packages/sensor-ui/src/observationStart.ts create mode 100644 packages/sensor-ui/src/previewLiveness.ts create mode 100644 packages/sensor-ui/src/previewPlayback.ts create mode 100644 packages/sensor-ui/src/useObservationStart.ts create mode 100644 packages/sensor-ui/src/useSensorPreview.ts create mode 100644 plugins/insta360-x4/frontend/src/X4Observation.tsx create mode 100644 plugins/insta360-x4/frontend/src/observationStart.ts create mode 100644 plugins/xgrids-k1/frontend/src/sensors/K1Observation.tsx create mode 100644 plugins/xgrids-k1/frontend/src/sensors/observation-3d.rbl create mode 100644 plugins/xgrids-k1/frontend/src/sensors/observation-plan.rbl create mode 100644 plugins/xgrids-k1/frontend/src/sensors/observationRerun.ts create mode 100644 scripts/build_observation_blueprints.py create mode 100644 scripts/manage_map_gateway_link.py create mode 100644 tests/fleet/test_sensor_queue.py create mode 100644 tests/test_map_gateway_link.py diff --git a/apps/control-station/src/components/map/mapPresentation.ts b/apps/control-station/src/components/map/mapPresentation.ts new file mode 100644 index 0000000..46698a3 --- /dev/null +++ b/apps/control-station/src/components/map/mapPresentation.ts @@ -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, + }; +} diff --git a/apps/control-station/src/core/fleet/observationLayout.ts b/apps/control-station/src/core/fleet/observationLayout.ts new file mode 100644 index 0000000..1497b9a --- /dev/null +++ b/apps/control-station/src/core/fleet/observationLayout.ts @@ -0,0 +1,23 @@ +export interface ObservationLayout { + version:1; order:string[]; hidden:string[]; layers:Record; + splits:Record; arrangement:'auto'|'columns'|'rows'; +} +export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'}); +const record=(value:unknown):Record=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; +export function decodeObservationLayout(value:unknown):ObservationLayout { + const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result; + const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[]; + result.order=ids(source.order);result.hidden=ids(source.hidden); + result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record; + result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))])); + 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}; +} diff --git a/apps/control-station/src/core/fleet/sensorTransport.ts b/apps/control-station/src/core/fleet/sensorTransport.ts new file mode 100644 index 0000000..9ee8749 --- /dev/null +++ b/apps/control-station/src/core/fleet/sensorTransport.ts @@ -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)}`), + }; +} diff --git a/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx b/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx index 2e1589d..1b93dc7 100644 --- a/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx +++ b/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx @@ -20,7 +20,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string {latest&&

Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}

} {value?.database_bytes!=null&&

Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}

} - {!value?:!value.available?:<> + {!value?:!value.available?:<>
undefined} disabled={pending} /> setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>} + + {source.key!=='map'&&{setFocused(source.key);setLayersOpen(true);}}>} + setFull(value=>value===source.key?null:source.key)}> + setVisible(source.key,false)}> +
+ + + + } + ,mounts.current.get(source.key)!,source.key))} + + {groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&({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(,media.current.get('map')!,'map-session')} + setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md">
+ {!active&&({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/>
;})} + {active?.view.layers&&({value,label:videoNames[value]??value}))} onChange={setLayer}/>
{groups.map(group=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/>}
{detail.recordings?.length?detail.recordings.map(item=>
{new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
):

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

}
} - {!detail&&

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

} + {!detail&&

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

} ; } diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index 46b2907..0c82e6b 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -46,7 +46,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null; return
{device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:
:<>
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(true)}>}{void refresh();}}>
- {!inventory?:connected.length===0?:{connected.map(item=>{ + {!inventory?:connected.length===0?:{connected.map(item=>{ const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id); const pending=localBusy.get(item.id)??operation?.action_id; const configured=item.configured??item.snapshot.enrollment==='enrolled'; diff --git a/packages/sensor-ui/src/extensions.ts b/packages/sensor-ui/src/extensions.ts index 4bd0917..492950b 100644 --- a/packages/sensor-ui/src/extensions.ts +++ b/packages/sensor-ui/src/extensions.ts @@ -15,6 +15,7 @@ export interface SensorEnrollmentProps { renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode; } export interface SensorUiContribution { + observation?:import('./observation').SensorObservationContribution; kind:string; Detail:ComponentType; icon:IconName; diff --git a/packages/sensor-ui/src/observation.ts b/packages/sensor-ui/src/observation.ts new file mode 100644 index 0000000..ef0efab --- /dev/null +++ b/packages/sensor-ui/src/observation.ts @@ -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; +} +export const sensorLayerLabels:Record={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'}; +export function cameraObservationViews(device:Sensor):SensorObservationView[] { + const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]})); + 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]);} diff --git a/packages/sensor-ui/src/observationStart.ts b/packages/sensor-ui/src/observationStart.ts new file mode 100644 index 0000000..505251a --- /dev/null +++ b/packages/sensor-ui/src/observationStart.ts @@ -0,0 +1,24 @@ +import {perform,type Sensor,type SensorTransport} from './contracts'; + +const pending=new Map>(); +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(device:Sensor,run:()=>Promise):Promise{ + const key=observationSession(device),current=pending.get(key);if(current)return current as Promise; + 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{ + 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}); + }); +} diff --git a/packages/sensor-ui/src/pluginSdk.ts b/packages/sensor-ui/src/pluginSdk.ts index c970748..ad7ac32 100644 --- a/packages/sensor-ui/src/pluginSdk.ts +++ b/packages/sensor-ui/src/pluginSdk.ts @@ -2,4 +2,6 @@ export * from './contracts'; export type * from './enrollment'; export type * from './rerunHost'; export type * from './extensions'; +export type * from './observation'; +export {ObservationHeader} from './ObservationHeader'; export * from '../../spatial-ui/src'; diff --git a/packages/sensor-ui/src/previewLiveness.ts b/packages/sensor-ui/src/previewLiveness.ts new file mode 100644 index 0000000..0eac0f4 --- /dev/null +++ b/packages/sensor-ui/src/previewLiveness.ts @@ -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'; + } +} diff --git a/packages/sensor-ui/src/previewPlayback.ts b/packages/sensor-ui/src/previewPlayback.ts new file mode 100644 index 0000000..67bdd78 --- /dev/null +++ b/packages/sensor-ui/src/previewPlayback.ts @@ -0,0 +1,17 @@ +type PreviewVideo = Pick; + +/** 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; }, + }; +} diff --git a/packages/sensor-ui/src/useObservationStart.ts b/packages/sensor-ui/src/useObservationStart.ts new file mode 100644 index 0000000..400cde6 --- /dev/null +++ b/packages/sensor-ui/src/useObservationStart.ts @@ -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){ + 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}; +} diff --git a/packages/sensor-ui/src/useSensorPreview.ts b/packages/sensor-ui/src/useSensorPreview.ts new file mode 100644 index 0000000..675a10a --- /dev/null +++ b/packages/sensor-ui/src/useSensorPreview.ts @@ -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;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,enabled=true,generation=0){ + const [state,setState]=useState('Подключение просмотра'); + const [telemetry,setTelemetry]=useState({}); + 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|undefined,retryTimer:ReturnType|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((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}; +} diff --git a/plugins/insta360-x4/frontend/src/RecoverySettings.tsx b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx index ff0f023..f6930c5 100644 --- a/plugins/insta360-x4/frontend/src/RecoverySettings.tsx +++ b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx @@ -6,7 +6,7 @@ export function RecoverySettings({value,online,enabled,pending,change}:{ }) { const failed=['exhausted','unavailable','preview_failed'].includes(value.phase); const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры': - ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено'; + ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстановление подключения':'Включено'; return {label}}>

На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.

diff --git a/plugins/insta360-x4/frontend/src/X4Detail.tsx b/plugins/insta360-x4/frontend/src/X4Detail.tsx index ee1a9d4..7c4a3a4 100644 --- a/plugins/insta360-x4/frontend/src/X4Detail.tsx +++ b/plugins/insta360-x4/frontend/src/X4Detail.tsx @@ -52,7 +52,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
{available&&status.recording===1&&

Запись продолжится после закрытия просмотра.

}
- + {settings?void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?

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

:null}
void action('files.list',{offset:0},'files.refresh')}>Обновить список}> diff --git a/plugins/insta360-x4/frontend/src/X4Observation.tsx b/plugins/insta360-x4/frontend/src/X4Observation.tsx new file mode 100644 index 0000000..e602b0a --- /dev/null +++ b/plugins/insta360-x4/frontend/src/X4Observation.tsx @@ -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;side:number;label:string;live:boolean}){ + const canvas=useRef(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 <>{!supported&&

Текущий формат изображения не содержит пару объективов.

}; +} +export function X4Observation({device,transport,enabled,views}:SensorObservationProps){ + const video=useRef(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 <>