Preserve onboard preview recordings across backpressure and share the complete spatial scene

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