Preserve onboard preview recordings across backpressure and share the complete spatial scene
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
|
||||
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceRendererProps } from "./contracts";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
@@ -91,26 +91,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
@@ -412,14 +392,9 @@ function SpatialWorkspace({
|
||||
: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spatial-workspace"
|
||||
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1">
|
||||
<div className="spatial-toolbar__actions">
|
||||
{recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -499,17 +474,7 @@ function SpatialWorkspace({
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="spatial-viewport-shell"
|
||||
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
@@ -523,20 +488,15 @@ function SpatialWorkspace({
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
{spatialControls && !recordedSource ? (
|
||||
<div className="scene-device-controls">
|
||||
<spatialControls.View
|
||||
</>}
|
||||
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pointCloudFocused ? (
|
||||
/>):null}
|
||||
sourceControls={<> {pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-focus-exit"
|
||||
@@ -567,22 +527,9 @@ function SpatialWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="scene-status scene-status--top-left"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="scene-metrics"
|
||||
aria-label="Метрики пространственной сцены"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<div>
|
||||
</>}
|
||||
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||
metrics={<> <div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
@@ -603,10 +550,7 @@ function SpatialWorkspace({
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
@@ -661,14 +605,8 @@ function SpatialWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
|
||||
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
|
||||
<span>Колесо · зум к курсору</span>
|
||||
<span>WASD · свободный проход</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
</>}
|
||||
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
@@ -691,8 +629,8 @@ function SpatialWorkspace({
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleMediaSources.map((source, index) => (
|
||||
</>}
|
||||
media={<> {visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
@@ -743,10 +681,7 @@ function SpatialWorkspace({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
) : null}</>} footer={ <div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
@@ -755,9 +690,7 @@ function SpatialWorkspace({
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>}/>;
|
||||
}
|
||||
|
||||
function CameraSourceCard({
|
||||
|
||||
@@ -191,11 +191,13 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
||||
assert.match(spatialControls, /stopLocalReceiver/);
|
||||
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialControls, /Не перемещайте устройство/);
|
||||
assert.match(spatialControls, /<K1SpatialSession/);
|
||||
const spatialSession = readFileSync(join(pluginFrontendRoot, "components/K1SpatialSession.tsx"), "utf8");
|
||||
assert.match(spatialSession, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialSession, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialSession, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialSession, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialSession, /Не перемещайте устройство/);
|
||||
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
|
||||
|
||||
@@ -37,7 +37,7 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||
assert.match(markup,/Остановить устройство/);
|
||||
assert.match(markup,/rerun-viewport__canvas/);
|
||||
assert.match(markup,/>Статус</);
|
||||
assert.match(markup,/>Пространственная модель</);
|
||||
assert.match(markup,/>Пространственная сцена</);
|
||||
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
||||
assert.ok(markup.includes('data-presented="false"'));
|
||||
assert.ok(!markup.includes('Loading Rerun'));
|
||||
@@ -53,8 +53,8 @@ test('calibration loader stays inside the primary action',()=>{
|
||||
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
|
||||
const markup=render(starting);
|
||||
assert.match(markup,/<button[^>]*aria-busy="true"[\s\S]*?nodedc-activity-indicator[\s\S]*?Запускаем устройство<\/button>/);
|
||||
assert.equal((markup.match(/nodedc-activity-indicator"/g)||[]).length,1);
|
||||
assert.doesNotMatch(markup,/Остановить устройство|k1-preview-spatial/);
|
||||
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
||||
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||
});
|
||||
test('completed STOP remains visible while the control connection is checked again',()=>{
|
||||
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
|
||||
|
||||
@@ -78,3 +78,16 @@ test('onboard camera admits immediate sourceopen and ignores callbacks after clo
|
||||
assert.equal(ready,1);assert.equal(failed,0);
|
||||
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
|
||||
});
|
||||
|
||||
test('a recovered media peer resumes a recording without reinserting old RRD batches',async()=>{
|
||||
const {previewRecording}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts');
|
||||
const sent=[];const cursor=previewRecording(value=>sent.push(value));
|
||||
const payload=new Uint8Array(12);payload.set([82,82,70,50]);
|
||||
assert.equal(cursor.accept(1,payload),true);
|
||||
// The ACK was lost, so the new peer resends the exact complete batch.
|
||||
assert.equal(cursor.accept(1,payload),false);
|
||||
assert.equal(cursor.after,1);
|
||||
assert.throws(()=>cursor.accept(3,payload),/cursor gap/);
|
||||
assert.equal(cursor.accept(2,payload),true);
|
||||
assert.equal(sent.length,2);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.8.9"
|
||||
VERSION = "0.8.10"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
|
||||
@@ -117,3 +117,12 @@ is preserved by package removal/upgrade; public R12 preparation leaves it in
|
||||
place and the service consumes the same systemd credential. Thus this artifact
|
||||
is an update for the existing R11 board, not a self-contained credential-bearing
|
||||
installer for a new board. The older private release is not altered.
|
||||
|
||||
## Installed outcome and failed stability acceptance
|
||||
|
||||
The owner subsequently reported installer exit 0. Read-only dpkg inspection
|
||||
confirmed Node 0.8.9 and K1 0.1.9, with active service and zero service restarts.
|
||||
Camera and cloud appeared, but repeated outbox Full / media backpressure caused
|
||||
preview recording replacement and history loss. R12 is not accepted as stable.
|
||||
The follow-up investigation and correction are recorded in
|
||||
`2026-09-07-k1-onboard-preview-continuity-r13.md`.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# K1 onboard preview continuity and canonical scene R13
|
||||
|
||||
## Observed regression
|
||||
|
||||
The owner confirmed R12 installation exit 0. Read-only package inspection
|
||||
confirmed Node 0.8.9 and K1 0.1.9. The service was active with NRestarts=0.
|
||||
Camera, points and trajectory appeared, but the live scene repeatedly went black
|
||||
and accumulation/route restarted. A bounded journal selection for that exact run
|
||||
showed repeated `Node RRD subscriber failed exception=Full`, plus media delivery
|
||||
RuntimeErrors. This is failed preview acceptance, not an accepted R12 release.
|
||||
|
||||
The encoded outbox previously raised Full after 500 ms. SCTP delivery also
|
||||
terminated a peer after two seconds of backpressure. Every replacement peer
|
||||
constructed a new RerunBridge, called begin_session and discarded trajectory and
|
||||
recording identity. Separately, the UI hid the entire scene whenever source age
|
||||
expired. No scanner/service restart is needed to explain these symptoms.
|
||||
|
||||
## Continuity contract
|
||||
|
||||
Preview protocol v3 keeps one native recording per acquisition view. The browser
|
||||
keeps its native channel and acknowledged batch cursor while disposable WebRTC
|
||||
peers recover. The board retains the corresponding subscriber for up to five
|
||||
minutes after disconnection. Every complete RRD batch is numbered and ACKed;
|
||||
a lost ACK causes retransmission of identical bytes, and the browser deduplicates
|
||||
it. Cursor mismatch/expired resumption reports a terminal reopen instruction;
|
||||
it must never silently create replacement history. Explicit view close frees
|
||||
capacity immediately; acquisition close retires every view.
|
||||
|
||||
The encoded outbox is bounded to two queued batches, one pending batch and one
|
||||
encoder-held batch, each no larger than 8 MiB. It waits without killing the
|
||||
recording. Decoded input coalesces independently per modality. The acquisition
|
||||
producer/archive never waits for the preview. Route snapshots come from the
|
||||
already bounded acquisition-owned trajectory, including motion during preview
|
||||
congestion. This is a disposable live view, not a replay of every archived frame.
|
||||
The chosen accumulation window still governs how long points remain visible.
|
||||
|
||||
Data-channel buffering is bounded at 256 KiB. Backpressure pauses sending while
|
||||
the peer is alive; a 30-second keepalive expiry bounds abandoned peers. Preview
|
||||
close/recovery does not send START, STOP, Wi-Fi, BLE or camera-producer commands.
|
||||
Camera lease renewal requires an existing active recording and restarts only
|
||||
the browser decoder. Source age includes time spent receiving each RRD payload;
|
||||
repeated metadata cannot make old points fresh. Stale history remains visibly
|
||||
marked instead of being replaced with a black canvas.
|
||||
|
||||
## Exact scene composition
|
||||
|
||||
The owner supplied the direct LAB scene as the composition reference. Shared
|
||||
`SpatialScene` now owns the existing toolbar, renderer allocation, device-control
|
||||
slot, source controls, engine status, metrics, navigation hint, timeline and media
|
||||
slots. Direct live and recorded hosts retain their existing renderers, profile
|
||||
factories, AI/replay controls and authority. Their markup is consumed through
|
||||
the shared scene instead of being independently re-created in the K1 Node UI.
|
||||
|
||||
The original K1 phase/telemetry capsule is extracted as `K1SpatialSession`, with
|
||||
pure shared phase/metrics vocabulary. Direct and onboard views use the same
|
||||
calibration/completion copy, timing, route, speed and capsule geometry. The
|
||||
onboard adapter receives the actual acquisition phase; manual STOP uses the
|
||||
existing guarded operation. The scene stays mounted during calibration and STOP.
|
||||
|
||||
The onboard scene uses the same ApplicationPanel header and floating camera
|
||||
window: title “Пространственная сцена”, live/wait status, expand and close. It
|
||||
omits LAB, descriptive subtitle, disk action and unused AI mode buttons. The
|
||||
three engine/layers/display tools, accumulation and movable/resizable camera
|
||||
remain. The enrollment “Настроить устройство” primary button occupies full width.
|
||||
No new Design Guideline primitive, navigation root, recorded blueprint, archive
|
||||
format, credential source or physical reconnection supervisor is introduced.
|
||||
|
||||
## Validation
|
||||
|
||||
- Full Control Station regression: 804/804 passed after shared composition/model
|
||||
extraction. Focused UI checks are repeated after final media lifecycle changes.
|
||||
- Python: 92 relevant tests across native RRD (6), media (10), Node control (22),
|
||||
installer (11) and camera gateway (43) covered by the combined pass and final
|
||||
affected-subset pass. The final native/media/control subset passed 38/38.
|
||||
- Regressions include a blocked native outbox beyond 500 ms, ACK loss/resumption
|
||||
of the same native subscriber, expired cursor rejection, explicit view disposal,
|
||||
SCTP backpressure beyond two seconds, native RRD fragmentation, and idle/resume
|
||||
of a real bounded loopback WebRTC peer with camera. No physical device commands.
|
||||
- Both UI typechecks and production/package builds are required before staging;
|
||||
final artifacts and installation outcome are recorded below when available.
|
||||
|
||||
The owner confirmed STOP; the paired Fleet snapshot was subsequently idle with
|
||||
control phase completed. Canonical Mission Core remains on port 8000. CUA exposes
|
||||
an in-app browser without documented cache/click controls, not the owner's
|
||||
Chrome session. Opening the home page is not clean-cache hardware acceptance.
|
||||
Required owner UI acceptance remains: clear Chrome cache before each run; START,
|
||||
calibration, camera/points, move the scanner, ensure route survives a delivery
|
||||
pause; move/resize camera; STOP and observe finalization. No Ops write or Git push
|
||||
is retried after the earlier automatic-approval rejections.
|
||||
|
||||
R13 is a matched public update: Node 0.8.10 and K1 0.1.10, protocol v3. It uses the
|
||||
existing root-owned encrypted K1 credential. No Keychain read or secret-bearing
|
||||
installer is part of this build. The standard installer safe-state guard remains.
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
||||
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||
export interface Sensor {
|
||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null};
|
||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
||||
live_settings?: Record<string,unknown>;
|
||||
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {Icon} from '@nodedc/ui-react';
|
||||
export function EmptySpatialStage({settings, title='Визуальный источник не назначен', detail='Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы открыть реальные данные.'}: {settings:{showGrid:boolean};title?:string;detail?:string}) {
|
||||
return <div className="empty-spatial-stage" data-grid={settings.showGrid?'true':undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true"/>
|
||||
<div className="spatial-axis" aria-hidden="true"><span data-axis="x">X</span><span data-axis="z">Z</span></div>
|
||||
<div className="empty-spatial-stage__message"><span className="empty-spatial-stage__icon"><Icon name="globe" size={20}/></span><strong>{title}</strong><p>{detail}</p></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {ReactNode, RefObject} from 'react';
|
||||
import {StatusBadge} from '@nodedc/ui-react';
|
||||
|
||||
/** The existing scene composition. Hosts supply transport/renderers and authority. */
|
||||
export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximized, toolbar,
|
||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer,
|
||||
navigationReady=false}: {
|
||||
viewportRef: RefObject<HTMLDivElement|null>; focused?:boolean; primaryFocused?:boolean;
|
||||
mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode;
|
||||
sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string};
|
||||
metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode;
|
||||
navigationReady?:boolean;
|
||||
}) {
|
||||
return <div className="spatial-workspace" data-focused={focused?'true':undefined}>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions">{toolbar}</div></div>
|
||||
<div ref={viewportRef} className="spatial-viewport-shell" data-primary-focused={primaryFocused?'true':undefined} data-media-maximized={mediaMaximized?'true':undefined}>
|
||||
{renderer}
|
||||
{deviceControls&&<div className="scene-device-controls">{deviceControls}</div>}
|
||||
{sourceControls}
|
||||
<div className="scene-status scene-status--top-left" aria-hidden={primaryFocused||mediaMaximized}>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={status.tone}>{status.label}</StatusBadge>
|
||||
{status.message?<small>{status.message}</small>:null}
|
||||
</div>
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={primaryFocused||mediaMaximized}>{metrics}</div>
|
||||
{overlays}
|
||||
{navigationReady&&!mediaMaximized&&<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||
{timeline}{media}
|
||||
</div>
|
||||
{footer}
|
||||
</div>;
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export * from './FloatingMediaWindow';
|
||||
export * from './ObservationTimeline';
|
||||
export * from './sceneSettings';
|
||||
export * from './ObservationSourcePicker';
|
||||
export * from './SpatialScene';
|
||||
export * from './EmptySpatialStage';
|
||||
|
||||
@@ -124,19 +124,8 @@ export interface XgridsHostFailureDiagnostic {
|
||||
redacted: true;
|
||||
}
|
||||
|
||||
export type AcquisitionState =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "awaiting_external_start"
|
||||
| "starting"
|
||||
| "acquiring"
|
||||
| "awaiting_external_stop"
|
||||
| "stopping"
|
||||
| "finalizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted"
|
||||
| "interrupted";
|
||||
export type {AcquisitionState} from './spatialSessionModel';
|
||||
import type {AcquisitionState} from './spatialSessionModel';
|
||||
|
||||
export type OperationStatus =
|
||||
| "accepted"
|
||||
@@ -832,32 +821,8 @@ export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary
|
||||
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
||||
}
|
||||
|
||||
export interface XgridsK1Metrics {
|
||||
pcl_frames?: number | null;
|
||||
pose_frames?: number | null;
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
ai_end_to_end_ms?: number | null;
|
||||
ai_end_to_end_p95_ms?: number | null;
|
||||
ai_frame_rate_hz?: number | null;
|
||||
ai_dropped_frames?: number | null;
|
||||
ai_stale_ms?: number | null;
|
||||
device_elapsed_seconds?: number | null;
|
||||
device_route_distance_meters?: number | null;
|
||||
device_speed_meters_per_second?: number | null;
|
||||
device_speed_mps?: number | null;
|
||||
elapsed_seconds?: number | null;
|
||||
route_distance_meters?: number | null;
|
||||
speed_meters_per_second?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
export type {XgridsK1Metrics} from './spatialSessionModel';
|
||||
import type {XgridsK1Metrics} from './spatialSessionModel';
|
||||
|
||||
export interface XgridsCameraPreviewDelivery {
|
||||
id: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ActivityIndicator, Button } from "@nodedc/ui-react";
|
||||
import {K1SpatialSession, k1SpatialPhasePresentation} from './K1SpatialSession';
|
||||
export {k1SpatialPhasePresentation} from './K1SpatialSession';
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type {
|
||||
AcquisitionState,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
} from "../api";
|
||||
import {
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "../lifecycle";
|
||||
import {
|
||||
deviceTelemetry,
|
||||
formatNumber,
|
||||
spatialActionFailure,
|
||||
} from "../presentation";
|
||||
import {
|
||||
@@ -34,12 +33,6 @@ import {
|
||||
} from "../runtimeContext";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface K1SpatialAuthorityState {
|
||||
controlAuthoritative: boolean;
|
||||
dataAuthoritative: boolean;
|
||||
@@ -64,85 +57,6 @@ export function k1SpatialAuthorityState(
|
||||
};
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: XgridsAcquisition,
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
const presentations: Record<AcquisitionState, PhasePresentation> = {
|
||||
preparing: {
|
||||
label: "Подготовка локального приёма",
|
||||
detail: "Проверка контура и создание сессии записи.",
|
||||
busy: true,
|
||||
},
|
||||
prepared: {
|
||||
label: "Приём подготовлен",
|
||||
detail: softwareCommanded
|
||||
? "Можно инициировать работу устройства из Mission Core."
|
||||
: "Программная команда K1 недоступна в текущем профиле.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
acquiring: {
|
||||
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
|
||||
detail: softwareCommanded
|
||||
? "Состояние получено из профилированного контура управления K1."
|
||||
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
|
||||
detail: softwareCommanded
|
||||
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
|
||||
: "Физическое состояние K1 остаётся неизвестным.",
|
||||
busy: true,
|
||||
},
|
||||
finalizing: {
|
||||
label: "Сохранение локальной записи",
|
||||
detail: "Не закрывайте Mission Core до завершения финализации.",
|
||||
busy: true,
|
||||
},
|
||||
completed: { label: "Приём завершён", detail: "", busy: false },
|
||||
failed: { label: "Ошибка приёма", detail: "", busy: false },
|
||||
aborted: { label: "Приём прерван", detail: "", busy: false },
|
||||
interrupted: { label: "Приём прерван", detail: "", busy: false },
|
||||
};
|
||||
return presentations[acquisition.state];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const wholeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(wholeSeconds / 3_600);
|
||||
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
|
||||
const remainder = wholeSeconds % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function runSpatialActiveStreamForceFinish(
|
||||
controller: Pick<
|
||||
XgridsK1Controller,
|
||||
@@ -276,30 +190,7 @@ export function K1SpatialControlsView({
|
||||
);
|
||||
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
|
||||
{telemetry.elapsedSeconds !== null ? (
|
||||
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
|
||||
) : null}
|
||||
{telemetry.routeDistanceMeters !== null ? (
|
||||
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
|
||||
) : null}
|
||||
{telemetry.speedMetersPerSecond !== null ? (
|
||||
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
|
||||
) : null}
|
||||
</div>
|
||||
<K1SpatialSession phase={phase} telemetry={telemetry}>
|
||||
{actionFailure ? (
|
||||
<div className="xgrids-k1-spatial-controls__error" role="alert">
|
||||
<strong>{actionFailure.title}</strong>
|
||||
@@ -345,7 +236,7 @@ export function K1SpatialControlsView({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</K1SpatialSession>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.xgrids-k1-spatial-controls {
|
||||
display: flex;
|
||||
min-width: min(42rem, 100%);
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 1rem;
|
||||
background: rgb(9 10 13 / 0.88);
|
||||
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase {
|
||||
display: flex;
|
||||
min-width: 11rem;
|
||||
flex: 1 1 15rem;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong,
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry > span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error {
|
||||
display: grid;
|
||||
max-width: 17rem;
|
||||
gap: 0.12rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.51rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label {
|
||||
display: block;
|
||||
width: 9.75rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.08;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label--local {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.xgrids-k1-spatial-controls {min-width:0}
|
||||
.xgrids-k1-spatial-controls__phase small,
|
||||
.xgrids-k1-spatial-controls__telemetry,
|
||||
.xgrids-k1-spatial-controls__error small {display:none}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {ActivityIndicator} from '@nodedc/ui-react';
|
||||
import type {AcquisitionState} from '../spatialSessionModel';
|
||||
import {formatNumber, type K1DeviceTelemetry} from '../spatialSessionModel';
|
||||
import './K1SpatialSession.css';
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: {state:AcquisitionState},
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
const presentations: Record<AcquisitionState, PhasePresentation> = {
|
||||
preparing: {
|
||||
label: "Подготовка локального приёма",
|
||||
detail: "Проверка контура и создание сессии записи.",
|
||||
busy: true,
|
||||
},
|
||||
prepared: {
|
||||
label: "Приём подготовлен",
|
||||
detail: softwareCommanded
|
||||
? "Можно инициировать работу устройства из Mission Core."
|
||||
: "Программная команда K1 недоступна в текущем профиле.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
acquiring: {
|
||||
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
|
||||
detail: softwareCommanded
|
||||
? "Состояние получено из профилированного контура управления K1."
|
||||
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
|
||||
detail: softwareCommanded
|
||||
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
|
||||
: "Физическое состояние K1 остаётся неизвестным.",
|
||||
busy: true,
|
||||
},
|
||||
finalizing: {
|
||||
label: "Сохранение локальной записи",
|
||||
detail: "Не закрывайте Mission Core до завершения финализации.",
|
||||
busy: true,
|
||||
},
|
||||
completed: { label: "Приём завершён", detail: "", busy: false },
|
||||
failed: { label: "Ошибка приёма", detail: "", busy: false },
|
||||
aborted: { label: "Приём прерван", detail: "", busy: false },
|
||||
interrupted: { label: "Приём прерван", detail: "", busy: false },
|
||||
};
|
||||
return presentations[acquisition.state];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const wholeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(wholeSeconds / 3_600);
|
||||
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
|
||||
const remainder = wholeSeconds % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function K1SpatialSession({phase,telemetry,children}:{phase:PhasePresentation;telemetry:K1DeviceTelemetry;children?:ReactNode}) {
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
|
||||
{telemetry.elapsedSeconds !== null ? (
|
||||
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
|
||||
) : null}
|
||||
{telemetry.routeDistanceMeters !== null ? (
|
||||
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
|
||||
) : null}
|
||||
{telemetry.speedMetersPerSecond !== null ? (
|
||||
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import {finiteMetric} from './spatialSessionModel';
|
||||
export {finiteMetric, deviceTelemetry, formatNumber} from './spatialSessionModel';
|
||||
export type {K1DeviceTelemetry} from './spatialSessionModel';
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
||||
@@ -295,9 +298,6 @@ export function eventStatusLabel(status: string): string {
|
||||
}[status] ?? "неизвестно";
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
|
||||
if (!metrics) return null;
|
||||
@@ -308,17 +308,6 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number |
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
function nonNegativeMetric(value: number | null | undefined): number | null {
|
||||
const finite = finiteMetric(value);
|
||||
return finite !== null && finite >= 0 ? finite : null;
|
||||
}
|
||||
|
||||
export interface K1DeviceTelemetry {
|
||||
elapsedSeconds: number | null;
|
||||
routeDistanceMeters: number | null;
|
||||
speedMetersPerSecond: number | null;
|
||||
}
|
||||
|
||||
export interface SpatialActionFailure {
|
||||
title: string;
|
||||
detail: string;
|
||||
@@ -336,31 +325,6 @@ export function spatialActionFailure(
|
||||
: null;
|
||||
}
|
||||
|
||||
export function deviceTelemetry(
|
||||
metrics: XgridsK1Metrics | null | undefined,
|
||||
): K1DeviceTelemetry {
|
||||
return {
|
||||
elapsedSeconds: nonNegativeMetric(
|
||||
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
|
||||
),
|
||||
routeDistanceMeters: nonNegativeMetric(
|
||||
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
|
||||
),
|
||||
speedMetersPerSecond: nonNegativeMetric(
|
||||
metrics?.device_speed_meters_per_second ??
|
||||
metrics?.device_speed_mps ??
|
||||
metrics?.speed_meters_per_second,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function sourceModeLabel(mode: string | null | undefined): string {
|
||||
if (mode === "live") return "Реальное время";
|
||||
|
||||
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
|
||||
}
|
||||
|
||||
return renderWindow({busy:pending,
|
||||
actions:confirmed?<Button variant="primary" disabled={pending}
|
||||
actions:confirmed?<Button variant="primary" style={{width:'100%'}} disabled={pending}
|
||||
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
|
||||
content:<div className="sensor-content" ref={content}>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {K1SpatialSession, k1SpatialPhasePresentation} from '../components/K1SpatialSession';
|
||||
import {deviceTelemetry} from '../spatialSessionModel';
|
||||
import type {AcquisitionState} from '../spatialSessionModel';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
@@ -11,6 +14,9 @@ import {pendingPreview} from './useK1Preview';
|
||||
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
||||
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
|
||||
const [preview,setPreview]=useState(pendingPreview);
|
||||
const [sceneOpen,setSceneOpen]=useState(['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition));
|
||||
const acquisitionId=device.control?.acquisition_id;
|
||||
useEffect(()=>{if(acquisitionId&&['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition))setSceneOpen(true);},[acquisitionId]);
|
||||
const scene=useK1SceneSettings(device,transport,failure);
|
||||
const running=useRef(false);
|
||||
const failedAction=useRef('');
|
||||
@@ -19,6 +25,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
|
||||
async function act(action:'start'|'stop'|'verify'){
|
||||
if(running.current||!enabled)return;
|
||||
if(action==='start')setSceneOpen(true);
|
||||
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
|
||||
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
||||
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});}
|
||||
@@ -26,6 +33,11 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
}
|
||||
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
|
||||
const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label);
|
||||
const phaseName = (device.control?.acquisition_phase || (pending==='start'?'starting':pending==='stop'?'stopping':streaming?'acquiring':'completed')) as AcquisitionState;
|
||||
const phase = k1SpatialPhasePresentation({state:phaseName},true) ?? {label:summary,detail:'',busy:!!pending};
|
||||
const sessionControls = <K1SpatialSession phase={!enabled?{label:'Нет свежих сведений с БК',detail:'Ожидаем восстановления связи.',busy:false}:phase} telemetry={deviceTelemetry(device.frames)}>
|
||||
{manual.showStop&&<Button size="compact" disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'} icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}><span className="xgrids-k1-spatial-controls__action-label">{pending==='stop'?'Остановка устройства…':'Остановить устройство и запись'}</span></Button>}
|
||||
</K1SpatialSession>;
|
||||
return <div className="sensor-content">
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
||||
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||
@@ -38,6 +50,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
|
||||
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
|
||||
{!sceneOpen&&manual.showStop&&<Button onClick={()=>setSceneOpen(true)}>Открыть пространственную сцену</Button>}
|
||||
{sceneOpen&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled&&streaming} scene={scene} openTool={setTool} close={()=>setSceneOpen(false)} sessionControls={sessionControls}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
|
||||
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
||||
import { ApplicationPanel, Icon, IconButton, StatusBadge } from '@nodedc/ui-react';
|
||||
import { SpatialScene, EmptySpatialStage, FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
||||
import type { Sensor, SensorTransport } from './runtime';
|
||||
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
||||
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
||||
import './livePreview.css';
|
||||
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
|
||||
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool, close, sessionControls }: {
|
||||
device: Sensor;
|
||||
transport: SensorTransport;
|
||||
createRerunHost: RerunHostFactory;
|
||||
@@ -13,8 +14,10 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
enabled: boolean;
|
||||
scene: K1SceneState;
|
||||
openTool: (tool: SceneTool) => void;
|
||||
close: () => void; sessionControls: ReactNode;
|
||||
}) {
|
||||
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
|
||||
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
|
||||
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
|
||||
@@ -31,6 +34,7 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setCameraMaximized(false);
|
||||
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
||||
const sources: SpatialSourceDescriptor[] = [
|
||||
@@ -41,29 +45,38 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
next.delete(id);
|
||||
else
|
||||
next.add(id); return next; });
|
||||
return <SettingsCard title="Пространственная модель" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'} actions={<IconButton label={expanded ? 'Свернуть просмотр' : 'Развернуть просмотр'} onClick={() => setExpanded(value => !value)}><Icon name={expanded ? 'minimize' : 'expand'}/></IconButton>}>
|
||||
<div className="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions"><SpatialToolbarActions openSource={() => openTool('source')} openLayers={() => openTool('layers')} openDisplay={() => openTool('display')}/></div></div>
|
||||
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
|
||||
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
|
||||
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
|
||||
</div>
|
||||
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
|
||||
<div className="scene-status scene-status--top-left" aria-hidden={cameraMaximized}>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span><StatusBadge tone={status.presented ? 'success' : 'neutral'}>{status.presented ? 'Визуализатор готов' : status.retry ? 'Восстановление связи' : 'Ожидаем свежие данные'}</StatusBadge>
|
||||
</div>
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
|
||||
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
|
||||
<div><span>До публикации</span><strong>{status.presented && device.frames?.mqtt_to_publish_ms != null ? device.frames.mqtt_to_publish_ms.toFixed(1) : '—'}<small> мс</small></strong></div>
|
||||
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
|
||||
</div>
|
||||
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||
{!cameraMaximized && <ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds => scene.stage({ accumulationSeconds })} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
||||
<FloatingMediaWindow title="Камера K1" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={() => { }} onClose={() => { setCameraMaximized(false); setVisible(current => { const next = new Set(current); next.delete('camera'); return next; }); }} status={<span className="floating-observation-window__status">{status.cameraPresented ? 'Эфир' : 'Ожидание'}</span>} footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
|
||||
<video className="observation-media__asset" style={{ visibility: status.cameraPresented ? 'visible' : 'hidden' }} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
{!status.cameraPresented && <div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
|
||||
</FloatingMediaWindow>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>;
|
||||
const hasScene = status.hasScene;
|
||||
const focusedAny = focused || cameraMaximized;
|
||||
return <ApplicationPanel title="Пространственная сцена" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'}
|
||||
expanded={expanded} onExpandedChange={setExpanded} onClose={close}
|
||||
headerTools={<StatusBadge tone={status.presented?'success':'neutral'}>{status.presented?'Эфир':'Ожидание эфира'}</StatusBadge>}>
|
||||
<SpatialScene viewportRef={viewport} focused={focusedAny} primaryFocused={focused} mediaMaximized={cameraMaximized}
|
||||
toolbar={<SpatialToolbarActions openSource={()=>openTool('source')} openLayers={()=>openTool('layers')} openDisplay={()=>openTool('display')}/>}
|
||||
renderer={<>
|
||||
<div className="rerun-viewport" data-status={hasScene?'ready':'loading'}>
|
||||
<div className="rerun-viewport__canvas" data-presented={hasScene&&visible.has('lidar')?'true':'false'} ref={spatial}/>
|
||||
</div>
|
||||
{!hasScene&&<EmptySpatialStage settings={scene.draft} title="Ожидаем данные K1" detail="Пространственная сцена появится после получения облака точек."/>}
|
||||
</>}
|
||||
deviceControls={sessionControls}
|
||||
sourceControls={focused?<IconButton className="scene-focus-exit" label="Выйти из полноэкранного режима облака точек" onClick={()=>setFocused(false)}><Icon name="minimize"/></IconButton>:!cameraMaximized&&<div className="scene-source-controls">
|
||||
<ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/>
|
||||
{hasScene&&visible.has('lidar')&&<IconButton className="scene-source-control" label="Развернуть облако точек" onClick={()=>setFocused(true)}><Icon name="expand"/></IconButton>}
|
||||
</div>}
|
||||
status={{tone:status.presented?'success':'neutral',label:status.presented?'Визуализатор готов':status.retry?'Восстановление связи':hasScene?'Нет свежих данных':'Ожидание источника'}}
|
||||
metrics={<>
|
||||
<div><span>КАДР/С</span><strong>{status.presented?(device.frames?.pcl_fps??device.frames?.frame_rate_hz??0).toFixed(1):'—'}</strong></div>
|
||||
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</strong></div>
|
||||
<div><span>До публикации</span><strong>{status.presented&&device.frames?.mqtt_to_publish_ms!=null?device.frames.mqtt_to_publish_ms.toFixed(1):'—'}<small> мс</small></strong></div>
|
||||
</>}
|
||||
navigationReady={hasScene}
|
||||
timeline={!focusedAny&&<ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds=>scene.stage({accumulationSeconds})} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
||||
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={()=>{}} onClose={()=>{setCameraMaximized(false);setVisible(current=>{const next=new Set(current);next.delete('camera');return next;});}}
|
||||
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
|
||||
footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
|
||||
<video className="observation-media__asset" style={{visibility:status.cameraPresented?'visible':'hidden'}} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
{!status.cameraPresented&&<div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
|
||||
</FloatingMediaWindow>}
|
||||
/>
|
||||
</ApplicationPanel>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v3';
|
||||
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
|
||||
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
|
||||
@@ -23,3 +23,17 @@ export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
export function assertRrd(bytes:Uint8Array){
|
||||
if(bytes.length<12||bytes[0]!==82||bytes[1]!==82||bytes[2]!==70||bytes[3]!==50)throw new Error('Invalid RRD recording');
|
||||
}
|
||||
|
||||
/** Kept by the acquisition view while disposable media peers reconnect. */
|
||||
export function previewRecording(send:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
let after=0;
|
||||
return {
|
||||
get after(){return after;},
|
||||
accept(sequence:number,payload:Uint8Array<ArrayBuffer>) {
|
||||
assertRrd(payload);
|
||||
if(!Number.isSafeInteger(sequence)||sequence<1||sequence>after+1)throw new Error('Preview cursor gap');
|
||||
if(sequence<=after)return false;
|
||||
send(payload);after=sequence;return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, type RefObject } from 'react';
|
||||
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
|
||||
import { MEDIA_PROTOCOL, previewFrames, previewRecording } from './previewFrames';
|
||||
import { previewCamera } from './previewCamera';
|
||||
import { previewFreshness } from './previewFreshness';
|
||||
export type PreviewStatus = {
|
||||
@@ -9,10 +9,11 @@ export type PreviewStatus = {
|
||||
camera: string;
|
||||
retry: boolean;
|
||||
presented: boolean;
|
||||
hasScene: boolean;
|
||||
cameraPresented: boolean;
|
||||
points: number;
|
||||
};
|
||||
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
|
||||
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, hasScene: false, cameraPresented: false, points: 0 };
|
||||
function privateCandidate(sdp: string): string {
|
||||
return sdp.split('\r\n').filter(line => {
|
||||
if (!line.startsWith('a=candidate:'))
|
||||
@@ -24,8 +25,7 @@ function privateCandidate(sdp: string): string {
|
||||
}
|
||||
export function useK1Preview(device: Sensor, transport: SensorTransport, createRerunHost: RerunHostFactory, spatial: RefObject<HTMLDivElement | null>, video: RefObject<HTMLVideoElement | null>, onStatus: (value: PreviewStatus) => void, generation = 0, enabled = true) {
|
||||
const [native, setNative] = useState<LiveRerunViewer | null>(null);
|
||||
// The native runtime belongs to the view. Media recovery opens a new recording
|
||||
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
|
||||
// The runtime and native channel belong to this acquisition view, not its peers.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const host = createRerunHost(spatial.current!);
|
||||
@@ -43,13 +43,31 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
||||
return () => { active = false; setNative(null); host.dispose(); };
|
||||
}, [createRerunHost, spatial, onStatus]);
|
||||
const [view, setView] = useState<{
|
||||
id: string; session: string; acquisition: string | null | undefined; channel: ReturnType<LiveRerunViewer['open_channel']>;
|
||||
recording: ReturnType<typeof previewRecording>; previousRecording: string | null; hasScene: boolean;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
|
||||
if (!native || !enabled)
|
||||
if (!native) return;
|
||||
const id = crypto.randomUUID();
|
||||
const channel = native.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + id);
|
||||
setView({id, session:device.snapshot.context.session_id, acquisition:device.control?.acquisition_id, channel, recording: previewRecording(bytes => {if (!channel.ready) throw new Error('Viewer unavailable'); channel.send_rrd(bytes);}), previousRecording: native.get_active_recording_id(), hasScene: false});
|
||||
return () => { setView(null); channel.close();
|
||||
void perform(transport, device, 'close-peer', {view_id:id, retire_view:true}).catch(()=>{});
|
||||
};
|
||||
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport]);
|
||||
useEffect(() => {
|
||||
const currentView = view?.session === device.snapshot.context.session_id && view?.acquisition === device.control?.acquisition_id;
|
||||
const paused = device.snapshot.acquisition === 'streaming'
|
||||
? {lidar:'Лидар: нет связи с БК', camera:'Камера: нет связи с БК'}
|
||||
: ['starting','preparing'].includes(device.snapshot.acquisition)
|
||||
? {} : {lidar:'Лидар: приём завершён', camera:'Камера: изображение остановлено'};
|
||||
onStatus({...pendingPreview, hasScene:currentView ? view?.hasScene ?? false : false, ...(!enabled ? paused : {})});
|
||||
if (!native || !view || !currentView || !enabled)
|
||||
return;
|
||||
let active = true, failed = false, peerID: string | undefined;
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined, follow: ReturnType<typeof setInterval> | undefined, iceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let status = { ...pendingPreview };
|
||||
let status = { ...pendingPreview, hasScene: view.hasScene };
|
||||
const update = (patch: Partial<PreviewStatus>) => { if (active) {
|
||||
const next = { ...status, ...patch };
|
||||
if (JSON.stringify(next) !== JSON.stringify(status)) {
|
||||
@@ -62,16 +80,23 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
||||
rrd.binaryType = 'arraybuffer';
|
||||
camera.binaryType = 'arraybuffer';
|
||||
const previousRecording = viewer.get_active_recording_id();
|
||||
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
|
||||
const {channel, previousRecording} = view;
|
||||
const fail = () => { if (!active || failed)
|
||||
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
|
||||
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
|
||||
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
|
||||
const decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
|
||||
let decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
|
||||
const cameraFrames = previewFrames(payload => decoder.push(payload));
|
||||
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
|
||||
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
|
||||
let batch: {sequence: number; lidar: Record<string, unknown>; received: number} | null = null;
|
||||
const rrdFrames = previewFrames(payload => {
|
||||
if (!batch || !channel.ready) throw new Error('Viewer unavailable');
|
||||
if (view.recording.accept(batch.sequence, payload)) {
|
||||
const age = batch.lidar.age_ms;
|
||||
freshness.receive({...batch.lidar, age_ms: typeof age === 'number' ? age + performance.now() - batch.received : age});
|
||||
}
|
||||
rrd.send(JSON.stringify({ack: batch.sequence}));
|
||||
batch = null;
|
||||
});
|
||||
rrd.onclose = () => { if (active && !failed)
|
||||
fail(); };
|
||||
camera.onclose = () => { if (active && !failed)
|
||||
@@ -80,8 +105,17 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
if (typeof event.data === 'string')
|
||||
freshness.receive(JSON.parse(event.data));
|
||||
if (typeof event.data === 'string') {
|
||||
const value = JSON.parse(event.data);
|
||||
if (value.type === 'preview-unavailable' && value.code === 'resume-expired') {
|
||||
failed = true; clearInterval(follow); clearInterval(keepalive);
|
||||
update({retry:false, presented:false, cameraPresented:false, lidar:'Сессия просмотра истекла. Закройте и откройте пространственную сцену.'});
|
||||
pc.close(); return;
|
||||
}
|
||||
if (batch || value.type !== 'rrd-batch' || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !value.lidar)
|
||||
throw new Error('Invalid RRD batch');
|
||||
batch = {...value, received: performance.now()};
|
||||
}
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
rrdFrames.push(event.data);
|
||||
}
|
||||
@@ -97,6 +131,9 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
const metadata = JSON.parse(event.data);
|
||||
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
||||
throw new Error('Invalid camera metadata');
|
||||
decoder.close();
|
||||
cameraDecoded = false; cameraFailed = false; lastVideoTime = -1;
|
||||
decoder = previewCamera(video.current!, () => {cameraDecoded = true; lastVideoProgress = performance.now();}, cameraFail);
|
||||
decoder.open(metadata.mime);
|
||||
}
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
@@ -128,13 +165,14 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
}
|
||||
catch { /* Native recording/timeline creation is asynchronous. */ }
|
||||
seenLidar ||= presented;
|
||||
view.hasScene ||= presented;
|
||||
const v = video.current!;
|
||||
if (cameraDecoded && v.currentTime !== lastVideoTime) {
|
||||
lastVideoTime = v.currentTime;
|
||||
lastVideoProgress = performance.now();
|
||||
}
|
||||
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
|
||||
update({ presented, cameraPresented, points: presented ? freshness.points : 0,
|
||||
update({ presented, hasScene: view.hasScene, cameraPresented, points: presented ? freshness.points : 0,
|
||||
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||
}, 250);
|
||||
@@ -154,7 +192,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
sdp: string;
|
||||
type: 'answer';
|
||||
media_protocol: string;
|
||||
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id });
|
||||
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id, view_id: view.id, after: view.recording.after });
|
||||
peerID = answer.peer_id;
|
||||
if (!active) {
|
||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||
@@ -190,9 +228,8 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
rrdFrames.close();
|
||||
cameraFrames.close();
|
||||
decoder.close();
|
||||
channel.close();
|
||||
if (peerID)
|
||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||
};
|
||||
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
|
||||
}, [native, view, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Pure K1 telemetry and phase vocabulary, shared by direct and onboard views. */
|
||||
export type AcquisitionState =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "awaiting_external_start"
|
||||
| "starting"
|
||||
| "acquiring"
|
||||
| "awaiting_external_stop"
|
||||
| "stopping"
|
||||
| "finalizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted"
|
||||
| "interrupted";
|
||||
|
||||
export interface XgridsK1Metrics {
|
||||
pcl_frames?: number | null;
|
||||
pose_frames?: number | null;
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
ai_end_to_end_ms?: number | null;
|
||||
ai_end_to_end_p95_ms?: number | null;
|
||||
ai_frame_rate_hz?: number | null;
|
||||
ai_dropped_frames?: number | null;
|
||||
ai_stale_ms?: number | null;
|
||||
device_elapsed_seconds?: number | null;
|
||||
device_route_distance_meters?: number | null;
|
||||
device_speed_meters_per_second?: number | null;
|
||||
device_speed_mps?: number | null;
|
||||
elapsed_seconds?: number | null;
|
||||
route_distance_meters?: number | null;
|
||||
speed_meters_per_second?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonNegativeMetric(value: number | null | undefined): number | null {
|
||||
const finite = finiteMetric(value);
|
||||
return finite !== null && finite >= 0 ? finite : null;
|
||||
}
|
||||
|
||||
export interface K1DeviceTelemetry {
|
||||
elapsedSeconds: number | null;
|
||||
routeDistanceMeters: number | null;
|
||||
speedMetersPerSecond: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function deviceTelemetry(
|
||||
metrics: XgridsK1Metrics | null | undefined,
|
||||
): K1DeviceTelemetry {
|
||||
return {
|
||||
elapsedSeconds: nonNegativeMetric(
|
||||
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
|
||||
),
|
||||
routeDistanceMeters: nonNegativeMetric(
|
||||
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
|
||||
),
|
||||
speedMetersPerSecond: nonNegativeMetric(
|
||||
metrics?.device_speed_meters_per_second ??
|
||||
metrics?.device_speed_mps ??
|
||||
metrics?.speed_meters_per_second,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -984,21 +984,6 @@ box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls {
|
||||
display: flex;
|
||||
min-width: min(42rem, 100%);
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 1rem;
|
||||
background: rgb(9 10 13 / 0.88);
|
||||
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls--recovery {
|
||||
display: grid;
|
||||
min-width: min(46rem, 100%);
|
||||
@@ -1108,90 +1093,6 @@ box-sizing: border-box;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase {
|
||||
display: flex;
|
||||
min-width: 11rem;
|
||||
flex: 1 1 15rem;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong,
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry > span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error {
|
||||
display: grid;
|
||||
max-width: 17rem;
|
||||
gap: 0.12rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.51rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label {
|
||||
display: block;
|
||||
width: 9.75rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.08;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label--local {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
.connection-action-progress {
|
||||
display: flex;
|
||||
min-height: 2.75rem;
|
||||
|
||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
||||
from debian import package # noqa: E402
|
||||
from runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.9"
|
||||
VERSION = "0.1.10"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: mission-core-node (>= 0.8.9), mission-core-node (<< 0.9.0),
|
||||
Depends: mission-core-node (>= 0.8.10), mission-core-node (<< 0.9.0),
|
||||
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||
Breaks: mission-core-node (<< 0.8.0)
|
||||
Replaces: mission-core-node (<< 0.8.0)
|
||||
|
||||
@@ -98,6 +98,7 @@ def project_sensor(snapshot, node_id):
|
||||
"network_applied": connected or attempt.get("phase") == "network_applied",
|
||||
"reason_code": None if connected else attempt.get("public_error_code"),
|
||||
"acquisition_id": acquisition.get("acquisition_id"),
|
||||
"acquisition_phase": acquisition.get("state"),
|
||||
},
|
||||
"live_settings": snapshot.get("viewer_settings", {}),
|
||||
"frames": snapshot.get("metrics", {}),
|
||||
@@ -149,6 +150,8 @@ class NodeK1Sensor:
|
||||
return item
|
||||
if action == "close-peer":
|
||||
await self.peers.close(params.get("peer_id"))
|
||||
if params.get("retire_view") is True:
|
||||
await self.peers.release_view(params.get("view_id"))
|
||||
return {"ok": True}
|
||||
if action == "offer":
|
||||
acquisition_id = item["control"]["acquisition_id"]
|
||||
|
||||
@@ -8,12 +8,14 @@ import queue
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from itertools import chain
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import aioice.ice
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||
|
||||
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
|
||||
from .node_rerun import PreviewResumeError
|
||||
|
||||
MEDIA_PROTOCOL = "missioncore.node-preview/v3"
|
||||
MAX_PAYLOAD = 8 * 1024 * 1024
|
||||
FRAGMENT_BYTES = 16384
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,11 +62,17 @@ class NodeMediaPeers:
|
||||
|
||||
async def offer(self, parameters):
|
||||
admit_sdp(parameters.get("sdp"))
|
||||
view_id, after = parameters.get("view_id"), parameters.get("after", 0)
|
||||
if not isinstance(view_id, str) or str(UUID(view_id)) != view_id:
|
||||
raise ValueError("Preview view identifier required")
|
||||
if type(after) is not int or not 0 <= after <= 2**53 - 1:
|
||||
raise ValueError("Invalid preview cursor")
|
||||
if len(self.items) >= 2:
|
||||
raise ValueError("Close another live viewer")
|
||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
identifier = "peer_" + uuid4().hex
|
||||
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()}
|
||||
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set(),
|
||||
"view_id": view_id, "after": after, "subscriber": None}
|
||||
self.items[identifier] = entry
|
||||
|
||||
@pc.on("datachannel")
|
||||
@@ -78,6 +86,14 @@ class NodeMediaPeers:
|
||||
def message(value):
|
||||
if value == "keepalive":
|
||||
entry["seen"] = time.monotonic()
|
||||
elif channel.label == "rrd" and isinstance(value, str) and len(value) < 80:
|
||||
try:
|
||||
ack = json.loads(value)
|
||||
sequence = ack.get("ack")
|
||||
if type(sequence) is int and entry["subscriber"] is not None:
|
||||
entry["subscriber"].acknowledge(sequence)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
|
||||
|
||||
@@ -110,7 +126,7 @@ class NodeMediaPeers:
|
||||
await self.close(identifier)
|
||||
raise
|
||||
|
||||
async def send(self, channel, payload):
|
||||
async def send(self, channel, payload, alive=None):
|
||||
if not 0 < len(payload) <= MAX_PAYLOAD:
|
||||
raise RuntimeError("Preview fragment exceeds bound")
|
||||
# Each binary_stream.read() is an independent RRD. SCTP messages are
|
||||
@@ -118,9 +134,9 @@ class NodeMediaPeers:
|
||||
parts = (payload[offset:offset + FRAGMENT_BYTES]
|
||||
for offset in range(0, len(payload), FRAGMENT_BYTES))
|
||||
for part in chain((b"MCF1" + len(payload).to_bytes(4, "big"),), parts):
|
||||
deadline = time.monotonic() + 2
|
||||
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
|
||||
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
|
||||
while channel.readyState != "open" or channel.bufferedAmount > 256 * 1024:
|
||||
if (channel.readyState in {"closed", "closing"}
|
||||
or (alive is not None and not alive())):
|
||||
raise RuntimeError("Preview consumer unavailable")
|
||||
await asyncio.sleep(0.01)
|
||||
channel.send(part)
|
||||
@@ -166,32 +182,58 @@ class NodeMediaPeers:
|
||||
try:
|
||||
entry = self.items[identifier]
|
||||
if channel.label == "rrd":
|
||||
subscriber = await asyncio.to_thread(self.hub.subscribe)
|
||||
# Admission only takes a short in-process lock. Keep it on this
|
||||
# task so cancellation cannot orphan an attached subscription.
|
||||
subscriber = self.hub.subscribe(entry["view_id"], entry["after"])
|
||||
entry["subscriber"] = subscriber
|
||||
else:
|
||||
lease = await self.camera_delivery(identifier, channel)
|
||||
if lease is None:
|
||||
return
|
||||
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
|
||||
def alive():
|
||||
return identifier in self.items and time.monotonic() - entry["seen"] < 30
|
||||
while alive():
|
||||
if subscriber:
|
||||
payload = await asyncio.to_thread(subscriber.read)
|
||||
batch = subscriber.next_batch(wait=False)
|
||||
if batch is None:
|
||||
break
|
||||
if not batch:
|
||||
await asyncio.sleep(0.025)
|
||||
continue
|
||||
sequence, payload, frame = batch
|
||||
channel.send(json.dumps({"type": "rrd-batch", "sequence": sequence,
|
||||
"lidar": subscriber.snapshot(frame)}))
|
||||
else:
|
||||
try:
|
||||
segment = await asyncio.to_thread(lease.segments.get, 0.5)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if segment is None:
|
||||
break
|
||||
self.camera.release_delivery(lease, client_closed=True)
|
||||
lease = None
|
||||
lease = await self.camera_delivery(identifier, channel)
|
||||
if lease is None:
|
||||
break
|
||||
continue
|
||||
kind, payload = segment
|
||||
if kind == "media":
|
||||
self.camera.mark_streaming(lease)
|
||||
if payload is None:
|
||||
break
|
||||
if payload:
|
||||
await self.send(channel, payload)
|
||||
await self.send(channel, payload, alive)
|
||||
if subscriber:
|
||||
# Ordered after native bytes. Age is source arrival age,
|
||||
# not time spent replaying an encoded preview backlog.
|
||||
channel.send(json.dumps(subscriber.snapshot()))
|
||||
# One in-flight complete native batch. Network stalls
|
||||
# pause this disposable delivery; the recorder keeps running.
|
||||
while alive() and subscriber.pending is not None:
|
||||
await asyncio.sleep(0.01)
|
||||
except PreviewResumeError:
|
||||
if channel.readyState == "open":
|
||||
channel.send(json.dumps({"type": "preview-unavailable", "code": "resume-expired"}))
|
||||
# Let the receiver consume the terminal reason and close its peer.
|
||||
deadline = time.monotonic() + 1
|
||||
while channel.readyState == "open" and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.025)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as error:
|
||||
@@ -199,7 +241,7 @@ class NodeMediaPeers:
|
||||
channel.label, type(error).__name__)
|
||||
finally:
|
||||
if subscriber:
|
||||
subscriber.close()
|
||||
subscriber.release()
|
||||
if lease:
|
||||
self.camera.release_delivery(lease, client_closed=True)
|
||||
if channel.label == "camera":
|
||||
@@ -216,6 +258,14 @@ class NodeMediaPeers:
|
||||
with suppress(Exception):
|
||||
await entry["pc"].close()
|
||||
|
||||
async def release_view(self, view_id):
|
||||
if not isinstance(view_id, str) or str(UUID(view_id)) != view_id:
|
||||
raise ValueError("Invalid preview view identifier")
|
||||
for identifier, entry in list(self.items.items()):
|
||||
if entry["view_id"] == view_id:
|
||||
await self.close(identifier)
|
||||
self.hub.release_view(view_id)
|
||||
|
||||
async def close_all(self):
|
||||
for identifier in list(self.items):
|
||||
await self.close(identifier)
|
||||
|
||||
+129
-28
@@ -1,8 +1,8 @@
|
||||
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
|
||||
|
||||
Every viewer receives a fresh native recording including StoreInfo/blueprint.
|
||||
Latest-value queues discard decoded preview frames before encoding; encoded
|
||||
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
|
||||
Each view owns one recording for the acquisition, including across peer recovery.
|
||||
Decoded frames coalesce before encoding; a bounded encoded outbox waits for the
|
||||
viewer. Only complete, acknowledged RRD batches leave that outbox.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -17,13 +17,25 @@ from k1link.viewer.rerun_bridge import RerunBridge
|
||||
|
||||
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
||||
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
|
||||
RESUME_GRACE_SECONDS = 300.0
|
||||
logger = logging.getLogger(__name__)
|
||||
_CURRENT_FRAME = object()
|
||||
|
||||
|
||||
class PreviewResumeError(ValueError):
|
||||
"""A view must be explicitly reopened; silently resetting history is forbidden."""
|
||||
|
||||
|
||||
class RrdSubscriber:
|
||||
def __init__(self, settings_provider):
|
||||
def __init__(self, settings_provider, path_provider=None):
|
||||
self.closed = threading.Event()
|
||||
self.inputs = queue.Queue(maxsize=2)
|
||||
self.inputs = {}
|
||||
self.input_ready = threading.Condition()
|
||||
self.delivery_lock = threading.Lock()
|
||||
self.pending = None
|
||||
self.batch_sequence = 0
|
||||
self.detached_at = None
|
||||
self.path_provider = path_provider
|
||||
self.output = queue.Queue(maxsize=2)
|
||||
self.settings_provider = settings_provider
|
||||
self.last_frame = None
|
||||
@@ -33,22 +45,55 @@ class RrdSubscriber:
|
||||
def offer(self, envelope):
|
||||
if self.closed.is_set():
|
||||
return
|
||||
with suppress(queue.Full):
|
||||
if self.inputs.full():
|
||||
with suppress(queue.Empty):
|
||||
self.inputs.get_nowait()
|
||||
self.inputs.put_nowait(envelope)
|
||||
with self.input_ready:
|
||||
# Keep one latest envelope per modality: a pose burst must not
|
||||
# starve PCL, and no network wait reaches the acquisition producer.
|
||||
self.inputs[type(envelope)] = envelope
|
||||
self.input_ready.notify()
|
||||
|
||||
def read(self):
|
||||
def attach(self, after=0):
|
||||
if self.closed.is_set() or not self.delivery_lock.acquire(blocking=False):
|
||||
raise RuntimeError("Preview is closed or still attached")
|
||||
if after != self.batch_sequence and not (
|
||||
self.pending is not None and after == self.batch_sequence - 1):
|
||||
self.delivery_lock.release()
|
||||
raise PreviewResumeError("Preview cursor does not match recording")
|
||||
self.acknowledge(after)
|
||||
self.detached_at = None
|
||||
return self
|
||||
|
||||
def release(self):
|
||||
self.detached_at = time.monotonic()
|
||||
self.delivery_lock.release()
|
||||
|
||||
def next_batch(self, *, wait=True):
|
||||
if self.pending is None:
|
||||
value = self._read(wait=wait)
|
||||
if not value:
|
||||
return value
|
||||
self.batch_sequence += 1
|
||||
self.pending = (self.batch_sequence, *value)
|
||||
return self.pending
|
||||
|
||||
def acknowledge(self, sequence):
|
||||
if self.pending is not None and sequence == self.pending[0]:
|
||||
self.pending = None
|
||||
|
||||
def _read(self, *, wait=True):
|
||||
if self.closed.is_set():
|
||||
return None
|
||||
try:
|
||||
return self.output.get(timeout=0.5)
|
||||
return self.output.get(timeout=0.5) if wait else self.output.get_nowait()
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
def snapshot(self):
|
||||
frame = self.last_frame
|
||||
def read(self):
|
||||
# Kept for native sink inspection; media delivery uses acknowledged batches.
|
||||
value = self._read()
|
||||
return value[0] if value else value
|
||||
|
||||
def snapshot(self, frame=_CURRENT_FRAME):
|
||||
frame = self.last_frame if frame is _CURRENT_FRAME else frame
|
||||
return {"type": "lidar-state", "sequence": frame[0] if frame else 0,
|
||||
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
|
||||
"points": frame[2] if frame else 0}
|
||||
@@ -66,7 +111,8 @@ class RrdSubscriber:
|
||||
return "webrtc+rrd://" + str(uuid4())
|
||||
|
||||
try:
|
||||
bridge = RerunBridge(settings_provider=self.settings_provider, recording_output=output)
|
||||
bridge = PreviewRerunBridge(settings_provider=self.settings_provider,
|
||||
recording_output=output, path_provider=self.path_provider)
|
||||
bridge.begin_session()
|
||||
while not self.closed.is_set():
|
||||
payload = binary.read()
|
||||
@@ -75,13 +121,21 @@ class RrdSubscriber:
|
||||
if payload and len(payload) > MAX_ENCODED_CHUNK:
|
||||
break
|
||||
if payload:
|
||||
# Bound both bytes and waiting time. The archive/producer
|
||||
# never waits for this disposable preview subscription.
|
||||
self.output.put(payload, timeout=0.5)
|
||||
try:
|
||||
envelope = self.inputs.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
# Backpressure is a pause, never EOF. At most two encoded
|
||||
# batches plus this one and the in-flight batch are retained.
|
||||
# Do not drop encoded bytes or restart a native recording.
|
||||
while not self.closed.is_set():
|
||||
try:
|
||||
self.output.put((payload, self.last_frame), timeout=0.1)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
with self.input_ready:
|
||||
if not self.inputs:
|
||||
self.input_ready.wait(timeout=0.1)
|
||||
if not self.inputs:
|
||||
continue
|
||||
envelope = self.inputs.pop(next(iter(self.inputs)))
|
||||
received = envelope.context.received_monotonic_ns
|
||||
if (received is not None
|
||||
and (time.monotonic_ns() - received) / 1_000_000_000
|
||||
@@ -104,10 +158,28 @@ class RrdSubscriber:
|
||||
binary.read()
|
||||
|
||||
|
||||
class PreviewRerunBridge(RerunBridge):
|
||||
def __init__(self, *, path_provider=None, **kwargs):
|
||||
self.path_provider = path_provider
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _append_trajectory_pose(self, position, source_time_ns):
|
||||
if self.path_provider is None:
|
||||
return super()._append_trajectory_pose(position, source_time_ns)
|
||||
# Route history belongs to the acquisition, including movement during
|
||||
# preview congestion. The primary bridge already bounds its point count.
|
||||
path = self.path_provider()
|
||||
if self._path == path:
|
||||
return False
|
||||
self._path = path
|
||||
return True
|
||||
|
||||
|
||||
class NodeRerunBridge(RerunBridge):
|
||||
def __init__(self, **kwargs):
|
||||
self.lock = threading.Lock()
|
||||
self.subscribers = []
|
||||
self.views = {}
|
||||
self.latest = {}
|
||||
|
||||
def output(recording):
|
||||
@@ -124,6 +196,7 @@ class NodeRerunBridge(RerunBridge):
|
||||
self.binary.read()
|
||||
with self.lock:
|
||||
self.latest[type(envelope)] = (time.monotonic(), envelope)
|
||||
self._expire_views()
|
||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||
for subscriber in self.subscribers:
|
||||
subscriber.offer(envelope)
|
||||
@@ -132,24 +205,48 @@ class NodeRerunBridge(RerunBridge):
|
||||
super().process_perception(frame)
|
||||
self.binary.read()
|
||||
|
||||
def subscribe(self):
|
||||
def _expire_views(self):
|
||||
for key, value in list(self.views.items()):
|
||||
if (value.closed.is_set() or (value.detached_at is not None
|
||||
and time.monotonic() - value.detached_at > RESUME_GRACE_SECONDS)):
|
||||
value.close()
|
||||
del self.views[key]
|
||||
|
||||
def subscribe(self, view_id=None, after=0):
|
||||
with self.lock:
|
||||
self._expire_views()
|
||||
if self._closed:
|
||||
raise RuntimeError("Live acquisition is not active")
|
||||
if view_id in self.views:
|
||||
return self.views[view_id].attach(after)
|
||||
if after:
|
||||
raise PreviewResumeError("Preview recording expired; reopen the view")
|
||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||
if self._closed or len(self.subscribers) >= 2:
|
||||
raise RuntimeError("Live viewer unavailable")
|
||||
subscriber = RrdSubscriber(self._settings_provider)
|
||||
if len(self.subscribers) >= 2:
|
||||
raise RuntimeError("Close another live viewer")
|
||||
subscriber = RrdSubscriber(self._settings_provider, lambda: list(self._path))
|
||||
for observed, envelope in self.latest.values():
|
||||
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
|
||||
subscriber.offer(envelope)
|
||||
self.subscribers.append(subscriber)
|
||||
if view_id is not None:
|
||||
self.views[view_id] = subscriber
|
||||
subscriber.attach()
|
||||
return subscriber
|
||||
|
||||
def release_view(self, view_id):
|
||||
with self.lock:
|
||||
subscriber = self.views.pop(view_id, None)
|
||||
if subscriber is not None:
|
||||
subscriber.close()
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
for subscriber in self.subscribers:
|
||||
subscriber.close()
|
||||
self.subscribers.clear()
|
||||
self.latest.clear()
|
||||
self.views.clear()
|
||||
super().close()
|
||||
self.binary.read()
|
||||
|
||||
@@ -163,8 +260,12 @@ class NodeRerunHub:
|
||||
self.bridge = bridge
|
||||
return bridge
|
||||
|
||||
def subscribe(self):
|
||||
def subscribe(self, view_id=None, after=0):
|
||||
bridge = self.bridge
|
||||
if bridge is None:
|
||||
raise RuntimeError("Live acquisition is not active")
|
||||
return bridge.subscribe()
|
||||
return bridge.subscribe(view_id, after)
|
||||
|
||||
def release_view(self, view_id):
|
||||
if self.bridge is not None:
|
||||
self.bridge.release_view(view_id)
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
|
||||
position += 60 + length + length % 2
|
||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||
control = archive.extractfile("control").read().decode()
|
||||
assert "Depends: mission-core-node (>= 0.8.9)" in control
|
||||
assert "Depends: mission-core-node (>= 0.8.10)" in control
|
||||
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||
|
||||
|
||||
|
||||
+60
-10
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import queue
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||
@@ -36,7 +37,7 @@ def test_failed_media_offer_retires_peer_without_camera_channel(monkeypatch):
|
||||
peers = NodeMediaPeers(None, Camera())
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
await peers.offer({"sdp": SDP_HEADER})
|
||||
await peers.offer({"sdp": SDP_HEADER, "view_id": str(uuid4())})
|
||||
assert peers.items == {}
|
||||
finally:
|
||||
await peers.close_all()
|
||||
@@ -50,23 +51,28 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
|
||||
class Subscription:
|
||||
def __init__(self):
|
||||
self.pending = None
|
||||
self.output = queue.Queue()
|
||||
self.output.put(payload)
|
||||
|
||||
def read(self):
|
||||
def next_batch(self, *, wait=True):
|
||||
try:
|
||||
return self.output.get(timeout=0.1)
|
||||
self.pending = (1, self.output.get(timeout=0.1), None)
|
||||
return self.pending
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
def snapshot(self):
|
||||
def snapshot(self, frame=None):
|
||||
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
|
||||
|
||||
def close(self):
|
||||
def acknowledge(self, sequence):
|
||||
self.pending = None
|
||||
|
||||
def release(self):
|
||||
pass
|
||||
|
||||
class Hub:
|
||||
def subscribe(self):
|
||||
def subscribe(self, view_id, after):
|
||||
return Subscription()
|
||||
|
||||
class Camera:
|
||||
@@ -98,11 +104,14 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
return
|
||||
payloads.append(data)
|
||||
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
||||
channel.send('{"ack":1}')
|
||||
received.set()
|
||||
|
||||
try:
|
||||
await client.setLocalDescription(await client.createOffer())
|
||||
answer = await peers.offer({"sdp": client.localDescription.sdp})
|
||||
answer = await peers.offer({
|
||||
"sdp": client.localDescription.sdp, "view_id": str(uuid4()),
|
||||
})
|
||||
await client.setRemoteDescription(
|
||||
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
||||
)
|
||||
@@ -210,14 +219,23 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
||||
video = client.createDataChannel("camera", ordered=True)
|
||||
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||
|
||||
batch_sequence, remaining = 0, 0
|
||||
|
||||
@rrd.on("message")
|
||||
def rrd_message(data):
|
||||
nonlocal batch_sequence, remaining
|
||||
if isinstance(data, str):
|
||||
value = json.loads(data)
|
||||
if value["sequence"] == 1:
|
||||
batch_sequence = value["sequence"]
|
||||
if value["lidar"]["sequence"] == 1:
|
||||
resumed.set()
|
||||
elif data.startswith(b"MCF1") and remaining == 0:
|
||||
remaining = int.from_bytes(data[4:], "big")
|
||||
else:
|
||||
ready.set()
|
||||
remaining -= len(data)
|
||||
if remaining == 0:
|
||||
rrd.send(json.dumps({"ack": batch_sequence}))
|
||||
ready.set()
|
||||
|
||||
@video.on("message")
|
||||
def camera_message(data):
|
||||
@@ -226,7 +244,9 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
||||
|
||||
try:
|
||||
await client.setLocalDescription(await client.createOffer())
|
||||
answer = await peers.offer({"sdp": client.localDescription.sdp})
|
||||
answer = await peers.offer({
|
||||
"sdp": client.localDescription.sdp, "view_id": str(uuid4()),
|
||||
})
|
||||
await client.setRemoteDescription(
|
||||
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
||||
)
|
||||
@@ -251,3 +271,33 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
||||
assert not peers.items
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_network_backpressure_longer_than_two_seconds_is_a_pause():
|
||||
"""Bounded synthetic scheduler pause; no sockets, scanner or load generation."""
|
||||
import time
|
||||
|
||||
class Channel:
|
||||
readyState = "open"
|
||||
sent = []
|
||||
blocked = True
|
||||
|
||||
@property
|
||||
def bufferedAmount(self):
|
||||
return 1024 * 1024 if self.blocked else 0
|
||||
|
||||
def send(self, value):
|
||||
self.sent.append(value)
|
||||
|
||||
async def run():
|
||||
channel = Channel()
|
||||
peers = object.__new__(NodeMediaPeers)
|
||||
task = asyncio.create_task(peers.send(channel, b"small-synthetic-payload", lambda: True))
|
||||
started = time.monotonic()
|
||||
await asyncio.sleep(2.1)
|
||||
assert not task.done() and channel.sent == []
|
||||
channel.blocked = False
|
||||
await asyncio.wait_for(task, 1)
|
||||
assert time.monotonic() - started >= 2
|
||||
assert channel.sent == [b"MCF1\x00\x00\x00\x17", b"small-synthetic-payload"]
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -78,3 +78,63 @@ def test_reopened_viewer_does_not_replay_cached_points_after_source_pause(monkey
|
||||
finally:
|
||||
bridge.close()
|
||||
sub.thread.join(timeout=3)
|
||||
|
||||
|
||||
def test_slow_consumer_resumes_same_recording_and_replays_unacknowledged_batch():
|
||||
"""A >500ms delivery pause used to retire the recording and lose its route."""
|
||||
bridge = NodeRerunBridge()
|
||||
sub = bridge.subscribe("synthetic-view")
|
||||
try:
|
||||
batch = sub.next_batch()
|
||||
assert batch and batch[1].startswith(b"RRF2")
|
||||
sequence = batch[0]
|
||||
# Produce several tiny frames without draining the bounded outbox.
|
||||
for index in range(1, 8):
|
||||
bridge.process(points(index))
|
||||
time.sleep(0.12)
|
||||
assert not sub.closed.is_set()
|
||||
assert sub.output.qsize() <= 2
|
||||
old_thread = sub.thread
|
||||
sub.release()
|
||||
resumed = bridge.subscribe("synthetic-view", sequence - 1)
|
||||
assert resumed is sub and resumed.thread is old_thread
|
||||
assert resumed.next_batch() == batch # ACK lost: resend the exact RRD.
|
||||
resumed.acknowledge(sequence)
|
||||
following = resumed.next_batch()
|
||||
assert following[0] == sequence + 1
|
||||
resumed.release()
|
||||
again = bridge.subscribe("synthetic-view", following[0])
|
||||
assert again is sub and again.pending is None # Delivered ACK lost at sender.
|
||||
again.release()
|
||||
finally:
|
||||
bridge.close()
|
||||
sub.thread.join(timeout=3)
|
||||
assert not sub.thread.is_alive()
|
||||
|
||||
|
||||
def test_resumption_cannot_silently_replace_expired_recording():
|
||||
import pytest
|
||||
bridge = NodeRerunBridge()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="expired"):
|
||||
bridge.subscribe("missing-view", 2)
|
||||
assert bridge.subscribers == []
|
||||
finally:
|
||||
bridge.close()
|
||||
|
||||
|
||||
def test_closing_view_releases_capacity_without_waiting_for_resume_grace():
|
||||
bridge = NodeRerunBridge()
|
||||
views = []
|
||||
try:
|
||||
for index in range(4):
|
||||
sub = bridge.subscribe(f"view-{index}")
|
||||
views.append(sub)
|
||||
sub.release()
|
||||
bridge.release_view(f"view-{index}")
|
||||
assert sub.closed.is_set()
|
||||
assert bridge.views == {}
|
||||
finally:
|
||||
bridge.close()
|
||||
for sub in views:
|
||||
sub.thread.join(timeout=3)
|
||||
|
||||
Reference in New Issue
Block a user