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 {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -33,7 +34,6 @@ import {
|
|||||||
type WorkspaceDefinition,
|
type WorkspaceDefinition,
|
||||||
} from "../productModel";
|
} from "../productModel";
|
||||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
|
||||||
import type { WorkspaceRendererProps } from "./contracts";
|
import type { WorkspaceRendererProps } from "./contracts";
|
||||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||||
@@ -91,26 +91,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
|||||||
</section>
|
</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({
|
function SpatialWorkspace({
|
||||||
state,
|
state,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
@@ -412,14 +392,9 @@ function SpatialWorkspace({
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||||
<div
|
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||||
className="spatial-workspace"
|
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||||
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<div className="spatial-toolbar" data-viewer-controls="v1">
|
|
||||||
<div className="spatial-toolbar__actions">
|
|
||||||
{recordedPerceptionSupported || livePerceptionAvailable ? (
|
|
||||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
@@ -499,17 +474,7 @@ function SpatialWorkspace({
|
|||||||
Сброс вида
|
Сброс вида
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
|
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||||
</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 ? (
|
|
||||||
<RerunViewport
|
<RerunViewport
|
||||||
profile={rerunViewerProfile}
|
profile={rerunViewerProfile}
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
@@ -523,20 +488,15 @@ function SpatialWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<EmptySpatialStage settings={sceneSettings} />
|
<EmptySpatialStage settings={sceneSettings} />
|
||||||
)}
|
)}
|
||||||
|
</>}
|
||||||
{spatialControls && !recordedSource ? (
|
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||||
<div className="scene-device-controls">
|
|
||||||
<spatialControls.View
|
|
||||||
model={spatialControls.model}
|
model={spatialControls.model}
|
||||||
host={{
|
host={{
|
||||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||||
}}
|
}}
|
||||||
/>
|
/>):null}
|
||||||
</div>
|
sourceControls={<> {pointCloudFocused ? (
|
||||||
) : null}
|
|
||||||
|
|
||||||
{pointCloudFocused ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="scene-focus-exit"
|
className="scene-focus-exit"
|
||||||
@@ -567,22 +527,9 @@ function SpatialWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>}
|
||||||
<div
|
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||||
className="scene-status scene-status--top-left"
|
metrics={<> <div>
|
||||||
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>
|
|
||||||
<span>КАДР/С</span>
|
<span>КАДР/С</span>
|
||||||
<strong>{formatNumber(frameRate)}</strong>
|
<strong>{formatNumber(frameRate)}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,10 +550,7 @@ function SpatialWorkspace({
|
|||||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||||
</div>
|
|
||||||
|
|
||||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
|
||||||
<div className="scene-adapter-note">
|
<div className="scene-adapter-note">
|
||||||
<Icon name="alert" />
|
<Icon name="alert" />
|
||||||
<span>
|
<span>
|
||||||
@@ -661,14 +605,8 @@ function SpatialWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
|
</>}
|
||||||
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
|
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||||
<span>Колесо · зум к курсору</span>
|
|
||||||
<span>WASD · свободный проход</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
|
||||||
<ObservationTimeline
|
<ObservationTimeline
|
||||||
active={presentedViewerStatus === "ready"}
|
active={presentedViewerStatus === "ready"}
|
||||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||||
@@ -691,8 +629,8 @@ function SpatialWorkspace({
|
|||||||
className="scene-timeline"
|
className="scene-timeline"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>}
|
||||||
{visibleMediaSources.map((source, index) => (
|
media={<> {visibleMediaSources.map((source, index) => (
|
||||||
<FloatingObservationWindow
|
<FloatingObservationWindow
|
||||||
key={source.id}
|
key={source.id}
|
||||||
source={source}
|
source={source}
|
||||||
@@ -743,10 +681,7 @@ function SpatialWorkspace({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}</>} footer={ <div className="spatial-contract-strip">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="spatial-contract-strip">
|
|
||||||
<span><i data-state="ready" />Облако точек</span>
|
<span><i data-state="ready" />Облако точек</span>
|
||||||
<span><i data-state="ready" />Траектория</span>
|
<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={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||||
<span><i data-state="contract" />Компоновка</span>
|
<span><i data-state="contract" />Компоновка</span>
|
||||||
</div>
|
</div>}/>;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CameraSourceCard({
|
function CameraSourceCard({
|
||||||
|
|||||||
@@ -191,11 +191,13 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
|||||||
assert.match(spatialControls, /role="alert"/);
|
assert.match(spatialControls, /role="alert"/);
|
||||||
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
||||||
assert.match(spatialControls, /stopLocalReceiver/);
|
assert.match(spatialControls, /stopLocalReceiver/);
|
||||||
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
|
assert.match(spatialControls, /<K1SpatialSession/);
|
||||||
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
|
const spatialSession = readFileSync(join(pluginFrontendRoot, "components/K1SpatialSession.tsx"), "utf8");
|
||||||
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
|
assert.match(spatialSession, /<ActivityIndicator size="compact"/);
|
||||||
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
|
assert.match(spatialSession, /aria-busy=\{phase\.busy\}/);
|
||||||
assert.match(spatialControls, /Не перемещайте устройство/);
|
assert.match(spatialSession, /K1 калибруется и готовит облако точек/);
|
||||||
|
assert.match(spatialSession, /Первые данные могут появиться через десятки секунд/);
|
||||||
|
assert.match(spatialSession, /Не перемещайте устройство/);
|
||||||
|
|
||||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||||
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
|
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,/Остановить устройство/);
|
||||||
assert.match(markup,/rerun-viewport__canvas/);
|
assert.match(markup,/rerun-viewport__canvas/);
|
||||||
assert.match(markup,/>Статус</);
|
assert.match(markup,/>Статус</);
|
||||||
assert.match(markup,/>Пространственная модель</);
|
assert.match(markup,/>Пространственная сцена</);
|
||||||
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
||||||
assert.ok(markup.includes('data-presented="false"'));
|
assert.ok(markup.includes('data-presented="false"'));
|
||||||
assert.ok(!markup.includes('Loading Rerun'));
|
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 starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
|
||||||
const markup=render(starting);
|
const markup=render(starting);
|
||||||
assert.match(markup,/<button[^>]*aria-busy="true"[\s\S]*?nodedc-activity-indicator[\s\S]*?Запускаем устройство<\/button>/);
|
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.match(markup,/K1 калибруется и готовит облако точек/);
|
||||||
assert.doesNotMatch(markup,/Остановить устройство|k1-preview-spatial/);
|
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||||
});
|
});
|
||||||
test('completed STOP remains visible while the control connection is checked again',()=>{
|
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}};
|
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);
|
assert.equal(ready,1);assert.equal(failed,0);
|
||||||
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
|
}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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.9"
|
VERSION = "0.8.10"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
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
|
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
|
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.
|
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 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 SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||||
export interface Sensor {
|
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>;
|
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;
|
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};
|
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 './ObservationTimeline';
|
||||||
export * from './sceneSettings';
|
export * from './sceneSettings';
|
||||||
export * from './ObservationSourcePicker';
|
export * from './ObservationSourcePicker';
|
||||||
|
export * from './SpatialScene';
|
||||||
|
export * from './EmptySpatialStage';
|
||||||
|
|||||||
@@ -124,19 +124,8 @@ export interface XgridsHostFailureDiagnostic {
|
|||||||
redacted: true;
|
redacted: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AcquisitionState =
|
export type {AcquisitionState} from './spatialSessionModel';
|
||||||
| "preparing"
|
import type {AcquisitionState} from './spatialSessionModel';
|
||||||
| "prepared"
|
|
||||||
| "awaiting_external_start"
|
|
||||||
| "starting"
|
|
||||||
| "acquiring"
|
|
||||||
| "awaiting_external_stop"
|
|
||||||
| "stopping"
|
|
||||||
| "finalizing"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "aborted"
|
|
||||||
| "interrupted";
|
|
||||||
|
|
||||||
export type OperationStatus =
|
export type OperationStatus =
|
||||||
| "accepted"
|
| "accepted"
|
||||||
@@ -832,32 +821,8 @@ export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary
|
|||||||
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XgridsK1Metrics {
|
export type {XgridsK1Metrics} from './spatialSessionModel';
|
||||||
pcl_frames?: number | null;
|
import type {XgridsK1Metrics} from './spatialSessionModel';
|
||||||
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 interface XgridsCameraPreviewDelivery {
|
export interface XgridsCameraPreviewDelivery {
|
||||||
id: string;
|
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 { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||||
import type {
|
import type {
|
||||||
AcquisitionState,
|
|
||||||
XgridsAcquisition,
|
|
||||||
XgridsK1State,
|
XgridsK1State,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "../lifecycle";
|
} from "../lifecycle";
|
||||||
import {
|
import {
|
||||||
deviceTelemetry,
|
deviceTelemetry,
|
||||||
formatNumber,
|
|
||||||
spatialActionFailure,
|
spatialActionFailure,
|
||||||
} from "../presentation";
|
} from "../presentation";
|
||||||
import {
|
import {
|
||||||
@@ -34,12 +33,6 @@ import {
|
|||||||
} from "../runtimeContext";
|
} from "../runtimeContext";
|
||||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||||
|
|
||||||
interface PhasePresentation {
|
|
||||||
label: string;
|
|
||||||
detail: string;
|
|
||||||
busy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface K1SpatialAuthorityState {
|
export interface K1SpatialAuthorityState {
|
||||||
controlAuthoritative: boolean;
|
controlAuthoritative: boolean;
|
||||||
dataAuthoritative: 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(
|
export function runSpatialActiveStreamForceFinish(
|
||||||
controller: Pick<
|
controller: Pick<
|
||||||
XgridsK1Controller,
|
XgridsK1Controller,
|
||||||
@@ -276,30 +190,7 @@ export function K1SpatialControlsView({
|
|||||||
);
|
);
|
||||||
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
||||||
return (
|
return (
|
||||||
<section
|
<K1SpatialSession phase={phase} telemetry={telemetry}>
|
||||||
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>
|
|
||||||
{actionFailure ? (
|
{actionFailure ? (
|
||||||
<div className="xgrids-k1-spatial-controls__error" role="alert">
|
<div className="xgrids-k1-spatial-controls__error" role="alert">
|
||||||
<strong>{actionFailure.title}</strong>
|
<strong>{actionFailure.title}</strong>
|
||||||
@@ -345,7 +236,7 @@ export function K1SpatialControlsView({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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 { StatusTone } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
||||||
@@ -295,9 +298,6 @@ export function eventStatusLabel(status: string): string {
|
|||||||
}[status] ?? "неизвестно";
|
}[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 {
|
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
|
||||||
if (!metrics) return 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;
|
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 {
|
export interface SpatialActionFailure {
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
@@ -336,31 +325,6 @@ export function spatialActionFailure(
|
|||||||
: null;
|
: 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 {
|
export function sourceModeLabel(mode: string | null | undefined): string {
|
||||||
if (mode === "live") return "Реальное время";
|
if (mode === "live") return "Реальное время";
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
|
|||||||
}
|
}
|
||||||
|
|
||||||
return renderWindow({busy:pending,
|
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}
|
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
|
||||||
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
|
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
|
||||||
content:<div className="sensor-content" ref={content}>
|
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 {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||||
import {K1LiveView} from './K1LiveView';
|
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}){
|
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 [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
|
||||||
const [preview,setPreview]=useState(pendingPreview);
|
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 scene=useK1SceneSettings(device,transport,failure);
|
||||||
const running=useRef(false);
|
const running=useRef(false);
|
||||||
const failedAction=useRef('');
|
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':'');
|
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
|
||||||
async function act(action:'start'|'stop'|'verify'){
|
async function act(action:'start'|'stop'|'verify'){
|
||||||
if(running.current||!enabled)return;
|
if(running.current||!enabled)return;
|
||||||
|
if(action==='start')setSceneOpen(true);
|
||||||
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
|
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();}
|
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(()=>{});}
|
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 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 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">
|
return <div className="sensor-content">
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
||||||
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||||
@@ -38,6 +50,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></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}/>}
|
{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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import type {ReactNode} from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
|
import { ApplicationPanel, Icon, IconButton, StatusBadge } from '@nodedc/ui-react';
|
||||||
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
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 { Sensor, SensorTransport } from './runtime';
|
||||||
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
||||||
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
||||||
import './livePreview.css';
|
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;
|
device: Sensor;
|
||||||
transport: SensorTransport;
|
transport: SensorTransport;
|
||||||
createRerunHost: RerunHostFactory;
|
createRerunHost: RerunHostFactory;
|
||||||
@@ -13,8 +14,10 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
scene: K1SceneState;
|
scene: K1SceneState;
|
||||||
openTool: (tool: SceneTool) => void;
|
openTool: (tool: SceneTool) => void;
|
||||||
|
close: () => void; sessionControls: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
|
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 [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
|
||||||
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
|
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
|
||||||
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
|
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]);
|
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||||
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
|
setFocused(false);
|
||||||
setCameraMaximized(false);
|
setCameraMaximized(false);
|
||||||
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
||||||
const sources: SpatialSourceDescriptor[] = [
|
const sources: SpatialSourceDescriptor[] = [
|
||||||
@@ -41,29 +45,38 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
|||||||
next.delete(id);
|
next.delete(id);
|
||||||
else
|
else
|
||||||
next.add(id); return next; });
|
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>}>
|
const hasScene = status.hasScene;
|
||||||
<div className="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
|
const focusedAny = focused || cameraMaximized;
|
||||||
<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>
|
return <ApplicationPanel title="Пространственная сцена" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'}
|
||||||
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
|
expanded={expanded} onExpandedChange={setExpanded} onClose={close}
|
||||||
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
|
headerTools={<StatusBadge tone={status.presented?'success':'neutral'}>{status.presented?'Эфир':'Ожидание эфира'}</StatusBadge>}>
|
||||||
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
|
<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>
|
</div>
|
||||||
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
|
{!hasScene&&<EmptySpatialStage settings={scene.draft} title="Ожидаем данные K1" detail="Пространственная сцена появится после получения облака точек."/>}
|
||||||
<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>
|
deviceControls={sessionControls}
|
||||||
</div>
|
sourceControls={focused?<IconButton className="scene-focus-exit" label="Выйти из полноэкранного режима облака точек" onClick={()=>setFocused(false)}><Icon name="minimize"/></IconButton>:!cameraMaximized&&<div className="scene-source-controls">
|
||||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
|
<ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/>
|
||||||
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
|
{hasScene&&visible.has('lidar')&&<IconButton className="scene-source-control" label="Развернуть облако точек" onClick={()=>setFocused(true)}><Icon name="expand"/></IconButton>}
|
||||||
<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>}
|
||||||
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
|
status={{tone:status.presented?'success':'neutral',label:status.presented?'Визуализатор готов':status.retry?'Восстановление связи':hasScene?'Нет свежих данных':'Ожидание источника'}}
|
||||||
</div>
|
metrics={<>
|
||||||
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
<div><span>КАДР/С</span><strong>{status.presented?(device.frames?.pcl_fps??device.frames?.frame_rate_hz??0).toFixed(1):'—'}</strong></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"/>}
|
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</strong></div>
|
||||||
<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>}>
|
<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>
|
||||||
<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>}
|
navigationReady={hasScene}
|
||||||
</FloatingMediaWindow>
|
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"/>}
|
||||||
</div>
|
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;});}}
|
||||||
</div>
|
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
|
||||||
</SettingsCard>;
|
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. */
|
/** 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;
|
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
|
||||||
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||||
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
|
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){
|
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');
|
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 { useEffect, useState, type RefObject } from 'react';
|
||||||
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
||||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
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 { previewCamera } from './previewCamera';
|
||||||
import { previewFreshness } from './previewFreshness';
|
import { previewFreshness } from './previewFreshness';
|
||||||
export type PreviewStatus = {
|
export type PreviewStatus = {
|
||||||
@@ -9,10 +9,11 @@ export type PreviewStatus = {
|
|||||||
camera: string;
|
camera: string;
|
||||||
retry: boolean;
|
retry: boolean;
|
||||||
presented: boolean;
|
presented: boolean;
|
||||||
|
hasScene: boolean;
|
||||||
cameraPresented: boolean;
|
cameraPresented: boolean;
|
||||||
points: number;
|
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 {
|
function privateCandidate(sdp: string): string {
|
||||||
return sdp.split('\r\n').filter(line => {
|
return sdp.split('\r\n').filter(line => {
|
||||||
if (!line.startsWith('a=candidate:'))
|
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) {
|
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);
|
const [native, setNative] = useState<LiveRerunViewer | null>(null);
|
||||||
// The native runtime belongs to the view. Media recovery opens a new recording
|
// The runtime and native channel belong to this acquisition view, not its peers.
|
||||||
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const host = createRerunHost(spatial.current!);
|
const host = createRerunHost(spatial.current!);
|
||||||
@@ -43,13 +43,31 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
|||||||
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
||||||
return () => { active = false; setNative(null); host.dispose(); };
|
return () => { active = false; setNative(null); host.dispose(); };
|
||||||
}, [createRerunHost, spatial, onStatus]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
|
if (!native) return;
|
||||||
if (!native || !enabled)
|
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;
|
return;
|
||||||
let active = true, failed = false, peerID: string | undefined;
|
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 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 update = (patch: Partial<PreviewStatus>) => { if (active) {
|
||||||
const next = { ...status, ...patch };
|
const next = { ...status, ...patch };
|
||||||
if (JSON.stringify(next) !== JSON.stringify(status)) {
|
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 });
|
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
||||||
rrd.binaryType = 'arraybuffer';
|
rrd.binaryType = 'arraybuffer';
|
||||||
camera.binaryType = 'arraybuffer';
|
camera.binaryType = 'arraybuffer';
|
||||||
const previousRecording = viewer.get_active_recording_id();
|
const {channel, previousRecording} = view;
|
||||||
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
|
|
||||||
const fail = () => { if (!active || failed)
|
const fail = () => { if (!active || failed)
|
||||||
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
|
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;
|
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
|
||||||
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
|
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 cameraFrames = previewFrames(payload => decoder.push(payload));
|
||||||
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
|
let batch: {sequence: number; lidar: Record<string, unknown>; received: number} | null = null;
|
||||||
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
|
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)
|
rrd.onclose = () => { if (active && !failed)
|
||||||
fail(); };
|
fail(); };
|
||||||
camera.onclose = () => { if (active && !failed)
|
camera.onclose = () => { if (active && !failed)
|
||||||
@@ -80,8 +105,17 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
|||||||
if (!active)
|
if (!active)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
if (typeof event.data === 'string')
|
if (typeof event.data === 'string') {
|
||||||
freshness.receive(JSON.parse(event.data));
|
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)
|
else if (event.data instanceof ArrayBuffer)
|
||||||
rrdFrames.push(event.data);
|
rrdFrames.push(event.data);
|
||||||
}
|
}
|
||||||
@@ -97,6 +131,9 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
|||||||
const metadata = JSON.parse(event.data);
|
const metadata = JSON.parse(event.data);
|
||||||
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
||||||
throw new Error('Invalid camera metadata');
|
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);
|
decoder.open(metadata.mime);
|
||||||
}
|
}
|
||||||
else if (event.data instanceof ArrayBuffer)
|
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. */ }
|
catch { /* Native recording/timeline creation is asynchronous. */ }
|
||||||
seenLidar ||= presented;
|
seenLidar ||= presented;
|
||||||
|
view.hasScene ||= presented;
|
||||||
const v = video.current!;
|
const v = video.current!;
|
||||||
if (cameraDecoded && v.currentTime !== lastVideoTime) {
|
if (cameraDecoded && v.currentTime !== lastVideoTime) {
|
||||||
lastVideoTime = v.currentTime;
|
lastVideoTime = v.currentTime;
|
||||||
lastVideoProgress = performance.now();
|
lastVideoProgress = performance.now();
|
||||||
}
|
}
|
||||||
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
|
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 ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||||
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||||
}, 250);
|
}, 250);
|
||||||
@@ -154,7 +192,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
|||||||
sdp: string;
|
sdp: string;
|
||||||
type: 'answer';
|
type: 'answer';
|
||||||
media_protocol: string;
|
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;
|
peerID = answer.peer_id;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||||
@@ -190,9 +228,8 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
|||||||
rrdFrames.close();
|
rrdFrames.close();
|
||||||
cameraFrames.close();
|
cameraFrames.close();
|
||||||
decoder.close();
|
decoder.close();
|
||||||
channel.close();
|
|
||||||
if (peerID)
|
if (peerID)
|
||||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
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 {
|
.xgrids-k1-spatial-controls--recovery {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: min(46rem, 100%);
|
min-width: min(46rem, 100%);
|
||||||
@@ -1108,90 +1093,6 @@ box-sizing: border-box;
|
|||||||
display: none;
|
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 {
|
.connection-action-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
|||||||
from debian import package # noqa: E402
|
from debian import package # noqa: E402
|
||||||
from runtime_payload import files as runtime_files # noqa: E402
|
from runtime_payload import files as runtime_files # noqa: E402
|
||||||
|
|
||||||
VERSION = "0.1.9"
|
VERSION = "0.1.10"
|
||||||
RESOURCES = (
|
RESOURCES = (
|
||||||
"plugins/xgrids-k1/profile_loader.py",
|
"plugins/xgrids-k1/profile_loader.py",
|
||||||
"plugins/xgrids-k1/plugin.manifest.json",
|
"plugins/xgrids-k1/plugin.manifest.json",
|
||||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
|||||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
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
|
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||||
Breaks: mission-core-node (<< 0.8.0)
|
Breaks: mission-core-node (<< 0.8.0)
|
||||||
Replaces: 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",
|
"network_applied": connected or attempt.get("phase") == "network_applied",
|
||||||
"reason_code": None if connected else attempt.get("public_error_code"),
|
"reason_code": None if connected else attempt.get("public_error_code"),
|
||||||
"acquisition_id": acquisition.get("acquisition_id"),
|
"acquisition_id": acquisition.get("acquisition_id"),
|
||||||
|
"acquisition_phase": acquisition.get("state"),
|
||||||
},
|
},
|
||||||
"live_settings": snapshot.get("viewer_settings", {}),
|
"live_settings": snapshot.get("viewer_settings", {}),
|
||||||
"frames": snapshot.get("metrics", {}),
|
"frames": snapshot.get("metrics", {}),
|
||||||
@@ -149,6 +150,8 @@ class NodeK1Sensor:
|
|||||||
return item
|
return item
|
||||||
if action == "close-peer":
|
if action == "close-peer":
|
||||||
await self.peers.close(params.get("peer_id"))
|
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}
|
return {"ok": True}
|
||||||
if action == "offer":
|
if action == "offer":
|
||||||
acquisition_id = item["control"]["acquisition_id"]
|
acquisition_id = item["control"]["acquisition_id"]
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import queue
|
|||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from uuid import uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import aioice.ice
|
import aioice.ice
|
||||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
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
|
MAX_PAYLOAD = 8 * 1024 * 1024
|
||||||
FRAGMENT_BYTES = 16384
|
FRAGMENT_BYTES = 16384
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -60,11 +62,17 @@ class NodeMediaPeers:
|
|||||||
|
|
||||||
async def offer(self, parameters):
|
async def offer(self, parameters):
|
||||||
admit_sdp(parameters.get("sdp"))
|
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:
|
if len(self.items) >= 2:
|
||||||
raise ValueError("Close another live viewer")
|
raise ValueError("Close another live viewer")
|
||||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||||
identifier = "peer_" + uuid4().hex
|
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
|
self.items[identifier] = entry
|
||||||
|
|
||||||
@pc.on("datachannel")
|
@pc.on("datachannel")
|
||||||
@@ -78,6 +86,14 @@ class NodeMediaPeers:
|
|||||||
def message(value):
|
def message(value):
|
||||||
if value == "keepalive":
|
if value == "keepalive":
|
||||||
entry["seen"] = time.monotonic()
|
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)))
|
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
|
||||||
|
|
||||||
@@ -110,7 +126,7 @@ class NodeMediaPeers:
|
|||||||
await self.close(identifier)
|
await self.close(identifier)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def send(self, channel, payload):
|
async def send(self, channel, payload, alive=None):
|
||||||
if not 0 < len(payload) <= MAX_PAYLOAD:
|
if not 0 < len(payload) <= MAX_PAYLOAD:
|
||||||
raise RuntimeError("Preview fragment exceeds bound")
|
raise RuntimeError("Preview fragment exceeds bound")
|
||||||
# Each binary_stream.read() is an independent RRD. SCTP messages are
|
# Each binary_stream.read() is an independent RRD. SCTP messages are
|
||||||
@@ -118,9 +134,9 @@ class NodeMediaPeers:
|
|||||||
parts = (payload[offset:offset + FRAGMENT_BYTES]
|
parts = (payload[offset:offset + FRAGMENT_BYTES]
|
||||||
for offset in range(0, len(payload), FRAGMENT_BYTES))
|
for offset in range(0, len(payload), FRAGMENT_BYTES))
|
||||||
for part in chain((b"MCF1" + len(payload).to_bytes(4, "big"),), parts):
|
for part in chain((b"MCF1" + len(payload).to_bytes(4, "big"),), parts):
|
||||||
deadline = time.monotonic() + 2
|
while channel.readyState != "open" or channel.bufferedAmount > 256 * 1024:
|
||||||
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
|
if (channel.readyState in {"closed", "closing"}
|
||||||
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
|
or (alive is not None and not alive())):
|
||||||
raise RuntimeError("Preview consumer unavailable")
|
raise RuntimeError("Preview consumer unavailable")
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
channel.send(part)
|
channel.send(part)
|
||||||
@@ -166,32 +182,58 @@ class NodeMediaPeers:
|
|||||||
try:
|
try:
|
||||||
entry = self.items[identifier]
|
entry = self.items[identifier]
|
||||||
if channel.label == "rrd":
|
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:
|
else:
|
||||||
lease = await self.camera_delivery(identifier, channel)
|
lease = await self.camera_delivery(identifier, channel)
|
||||||
if lease is None:
|
if lease is None:
|
||||||
return
|
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:
|
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:
|
else:
|
||||||
try:
|
try:
|
||||||
segment = await asyncio.to_thread(lease.segments.get, 0.5)
|
segment = await asyncio.to_thread(lease.segments.get, 0.5)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
if segment is None:
|
if segment is None:
|
||||||
|
self.camera.release_delivery(lease, client_closed=True)
|
||||||
|
lease = None
|
||||||
|
lease = await self.camera_delivery(identifier, channel)
|
||||||
|
if lease is None:
|
||||||
break
|
break
|
||||||
|
continue
|
||||||
kind, payload = segment
|
kind, payload = segment
|
||||||
if kind == "media":
|
if kind == "media":
|
||||||
self.camera.mark_streaming(lease)
|
self.camera.mark_streaming(lease)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
break
|
break
|
||||||
if payload:
|
if payload:
|
||||||
await self.send(channel, payload)
|
await self.send(channel, payload, alive)
|
||||||
if subscriber:
|
if subscriber:
|
||||||
# Ordered after native bytes. Age is source arrival age,
|
# One in-flight complete native batch. Network stalls
|
||||||
# not time spent replaying an encoded preview backlog.
|
# pause this disposable delivery; the recorder keeps running.
|
||||||
channel.send(json.dumps(subscriber.snapshot()))
|
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:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -199,7 +241,7 @@ class NodeMediaPeers:
|
|||||||
channel.label, type(error).__name__)
|
channel.label, type(error).__name__)
|
||||||
finally:
|
finally:
|
||||||
if subscriber:
|
if subscriber:
|
||||||
subscriber.close()
|
subscriber.release()
|
||||||
if lease:
|
if lease:
|
||||||
self.camera.release_delivery(lease, client_closed=True)
|
self.camera.release_delivery(lease, client_closed=True)
|
||||||
if channel.label == "camera":
|
if channel.label == "camera":
|
||||||
@@ -216,6 +258,14 @@ class NodeMediaPeers:
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await entry["pc"].close()
|
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):
|
async def close_all(self):
|
||||||
for identifier in list(self.items):
|
for identifier in list(self.items):
|
||||||
await self.close(identifier)
|
await self.close(identifier)
|
||||||
|
|||||||
+127
-26
@@ -1,8 +1,8 @@
|
|||||||
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
|
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
|
||||||
|
|
||||||
Every viewer receives a fresh native recording including StoreInfo/blueprint.
|
Each view owns one recording for the acquisition, including across peer recovery.
|
||||||
Latest-value queues discard decoded preview frames before encoding; encoded
|
Decoded frames coalesce before encoding; a bounded encoded outbox waits for the
|
||||||
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
|
viewer. Only complete, acknowledged RRD batches leave that outbox.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -17,13 +17,25 @@ from k1link.viewer.rerun_bridge import RerunBridge
|
|||||||
|
|
||||||
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
||||||
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
|
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
|
||||||
|
RESUME_GRACE_SECONDS = 300.0
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_CURRENT_FRAME = object()
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResumeError(ValueError):
|
||||||
|
"""A view must be explicitly reopened; silently resetting history is forbidden."""
|
||||||
|
|
||||||
|
|
||||||
class RrdSubscriber:
|
class RrdSubscriber:
|
||||||
def __init__(self, settings_provider):
|
def __init__(self, settings_provider, path_provider=None):
|
||||||
self.closed = threading.Event()
|
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.output = queue.Queue(maxsize=2)
|
||||||
self.settings_provider = settings_provider
|
self.settings_provider = settings_provider
|
||||||
self.last_frame = None
|
self.last_frame = None
|
||||||
@@ -33,22 +45,55 @@ class RrdSubscriber:
|
|||||||
def offer(self, envelope):
|
def offer(self, envelope):
|
||||||
if self.closed.is_set():
|
if self.closed.is_set():
|
||||||
return
|
return
|
||||||
with suppress(queue.Full):
|
with self.input_ready:
|
||||||
if self.inputs.full():
|
# Keep one latest envelope per modality: a pose burst must not
|
||||||
with suppress(queue.Empty):
|
# starve PCL, and no network wait reaches the acquisition producer.
|
||||||
self.inputs.get_nowait()
|
self.inputs[type(envelope)] = envelope
|
||||||
self.inputs.put_nowait(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():
|
if self.closed.is_set():
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except queue.Empty:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
def snapshot(self):
|
def read(self):
|
||||||
frame = self.last_frame
|
# 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,
|
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,
|
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
|
||||||
"points": frame[2] if frame else 0}
|
"points": frame[2] if frame else 0}
|
||||||
@@ -66,7 +111,8 @@ class RrdSubscriber:
|
|||||||
return "webrtc+rrd://" + str(uuid4())
|
return "webrtc+rrd://" + str(uuid4())
|
||||||
|
|
||||||
try:
|
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()
|
bridge.begin_session()
|
||||||
while not self.closed.is_set():
|
while not self.closed.is_set():
|
||||||
payload = binary.read()
|
payload = binary.read()
|
||||||
@@ -75,13 +121,21 @@ class RrdSubscriber:
|
|||||||
if payload and len(payload) > MAX_ENCODED_CHUNK:
|
if payload and len(payload) > MAX_ENCODED_CHUNK:
|
||||||
break
|
break
|
||||||
if payload:
|
if payload:
|
||||||
# Bound both bytes and waiting time. The archive/producer
|
# Backpressure is a pause, never EOF. At most two encoded
|
||||||
# never waits for this disposable preview subscription.
|
# batches plus this one and the in-flight batch are retained.
|
||||||
self.output.put(payload, timeout=0.5)
|
# Do not drop encoded bytes or restart a native recording.
|
||||||
|
while not self.closed.is_set():
|
||||||
try:
|
try:
|
||||||
envelope = self.inputs.get(timeout=0.1)
|
self.output.put((payload, self.last_frame), timeout=0.1)
|
||||||
except queue.Empty:
|
break
|
||||||
|
except queue.Full:
|
||||||
continue
|
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
|
received = envelope.context.received_monotonic_ns
|
||||||
if (received is not None
|
if (received is not None
|
||||||
and (time.monotonic_ns() - received) / 1_000_000_000
|
and (time.monotonic_ns() - received) / 1_000_000_000
|
||||||
@@ -104,10 +158,28 @@ class RrdSubscriber:
|
|||||||
binary.read()
|
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):
|
class NodeRerunBridge(RerunBridge):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.subscribers = []
|
self.subscribers = []
|
||||||
|
self.views = {}
|
||||||
self.latest = {}
|
self.latest = {}
|
||||||
|
|
||||||
def output(recording):
|
def output(recording):
|
||||||
@@ -124,6 +196,7 @@ class NodeRerunBridge(RerunBridge):
|
|||||||
self.binary.read()
|
self.binary.read()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.latest[type(envelope)] = (time.monotonic(), envelope)
|
self.latest[type(envelope)] = (time.monotonic(), envelope)
|
||||||
|
self._expire_views()
|
||||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||||
for subscriber in self.subscribers:
|
for subscriber in self.subscribers:
|
||||||
subscriber.offer(envelope)
|
subscriber.offer(envelope)
|
||||||
@@ -132,24 +205,48 @@ class NodeRerunBridge(RerunBridge):
|
|||||||
super().process_perception(frame)
|
super().process_perception(frame)
|
||||||
self.binary.read()
|
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:
|
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()]
|
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||||
if self._closed or len(self.subscribers) >= 2:
|
if len(self.subscribers) >= 2:
|
||||||
raise RuntimeError("Live viewer unavailable")
|
raise RuntimeError("Close another live viewer")
|
||||||
subscriber = RrdSubscriber(self._settings_provider)
|
subscriber = RrdSubscriber(self._settings_provider, lambda: list(self._path))
|
||||||
for observed, envelope in self.latest.values():
|
for observed, envelope in self.latest.values():
|
||||||
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
|
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
|
||||||
subscriber.offer(envelope)
|
subscriber.offer(envelope)
|
||||||
self.subscribers.append(subscriber)
|
self.subscribers.append(subscriber)
|
||||||
|
if view_id is not None:
|
||||||
|
self.views[view_id] = subscriber
|
||||||
|
subscriber.attach()
|
||||||
return subscriber
|
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):
|
def close(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for subscriber in self.subscribers:
|
for subscriber in self.subscribers:
|
||||||
subscriber.close()
|
subscriber.close()
|
||||||
self.subscribers.clear()
|
self.subscribers.clear()
|
||||||
self.latest.clear()
|
self.latest.clear()
|
||||||
|
self.views.clear()
|
||||||
super().close()
|
super().close()
|
||||||
self.binary.read()
|
self.binary.read()
|
||||||
|
|
||||||
@@ -163,8 +260,12 @@ class NodeRerunHub:
|
|||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
return bridge
|
return bridge
|
||||||
|
|
||||||
def subscribe(self):
|
def subscribe(self, view_id=None, after=0):
|
||||||
bridge = self.bridge
|
bridge = self.bridge
|
||||||
if bridge is None:
|
if bridge is None:
|
||||||
raise RuntimeError("Live acquisition is not active")
|
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
|
position += 60 + length + length % 2
|
||||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||||
control = archive.extractfile("control").read().decode()
|
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
|
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import queue
|
import queue
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
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())
|
peers = NodeMediaPeers(None, Camera())
|
||||||
try:
|
try:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await peers.offer({"sdp": SDP_HEADER})
|
await peers.offer({"sdp": SDP_HEADER, "view_id": str(uuid4())})
|
||||||
assert peers.items == {}
|
assert peers.items == {}
|
||||||
finally:
|
finally:
|
||||||
await peers.close_all()
|
await peers.close_all()
|
||||||
@@ -50,23 +51,28 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
|||||||
|
|
||||||
class Subscription:
|
class Subscription:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.pending = None
|
||||||
self.output = queue.Queue()
|
self.output = queue.Queue()
|
||||||
self.output.put(payload)
|
self.output.put(payload)
|
||||||
|
|
||||||
def read(self):
|
def next_batch(self, *, wait=True):
|
||||||
try:
|
try:
|
||||||
return self.output.get(timeout=0.1)
|
self.pending = (1, self.output.get(timeout=0.1), None)
|
||||||
|
return self.pending
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
def snapshot(self):
|
def snapshot(self, frame=None):
|
||||||
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
|
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
|
pass
|
||||||
|
|
||||||
class Hub:
|
class Hub:
|
||||||
def subscribe(self):
|
def subscribe(self, view_id, after):
|
||||||
return Subscription()
|
return Subscription()
|
||||||
|
|
||||||
class Camera:
|
class Camera:
|
||||||
@@ -98,11 +104,14 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
|||||||
return
|
return
|
||||||
payloads.append(data)
|
payloads.append(data)
|
||||||
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
||||||
|
channel.send('{"ack":1}')
|
||||||
received.set()
|
received.set()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.setLocalDescription(await client.createOffer())
|
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(
|
await client.setRemoteDescription(
|
||||||
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
||||||
)
|
)
|
||||||
@@ -210,13 +219,22 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
|||||||
video = client.createDataChannel("camera", ordered=True)
|
video = client.createDataChannel("camera", ordered=True)
|
||||||
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
batch_sequence, remaining = 0, 0
|
||||||
|
|
||||||
@rrd.on("message")
|
@rrd.on("message")
|
||||||
def rrd_message(data):
|
def rrd_message(data):
|
||||||
|
nonlocal batch_sequence, remaining
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
value = json.loads(data)
|
value = json.loads(data)
|
||||||
if value["sequence"] == 1:
|
batch_sequence = value["sequence"]
|
||||||
|
if value["lidar"]["sequence"] == 1:
|
||||||
resumed.set()
|
resumed.set()
|
||||||
|
elif data.startswith(b"MCF1") and remaining == 0:
|
||||||
|
remaining = int.from_bytes(data[4:], "big")
|
||||||
else:
|
else:
|
||||||
|
remaining -= len(data)
|
||||||
|
if remaining == 0:
|
||||||
|
rrd.send(json.dumps({"ack": batch_sequence}))
|
||||||
ready.set()
|
ready.set()
|
||||||
|
|
||||||
@video.on("message")
|
@video.on("message")
|
||||||
@@ -226,7 +244,9 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await client.setLocalDescription(await client.createOffer())
|
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(
|
await client.setRemoteDescription(
|
||||||
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
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
|
assert not peers.items
|
||||||
|
|
||||||
asyncio.run(run())
|
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:
|
finally:
|
||||||
bridge.close()
|
bridge.close()
|
||||||
sub.thread.join(timeout=3)
|
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