feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -87,6 +87,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
controller={controller}
|
||||
profileConfirmed={profileConfirmed}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface XgridsAcquisition {
|
||||
device_session_id: string;
|
||||
compatibility_profile_id: string;
|
||||
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
||||
project_name?: string | null;
|
||||
cleanup_pending?: boolean;
|
||||
requested_streams: string[];
|
||||
target_host: string;
|
||||
duration_seconds: number;
|
||||
@@ -119,6 +121,13 @@ export interface XgridsK1Metrics {
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: 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;
|
||||
}
|
||||
|
||||
@@ -216,6 +225,7 @@ export interface ConnectRequest {
|
||||
}
|
||||
|
||||
export interface PrepareAcquisitionRequest {
|
||||
project_name: string;
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
requested_streams?: RequestedStreamId[];
|
||||
@@ -229,6 +239,7 @@ export interface PrepareAcquisitionRequest {
|
||||
export type RequestedStreamId =
|
||||
| "spatial.point-cloud.live"
|
||||
| "spatial.pose.live"
|
||||
| "device.modeling.live"
|
||||
| "device.status.live"
|
||||
| "device.heartbeat.live";
|
||||
|
||||
@@ -257,6 +268,7 @@ export interface AbortAcquisitionRequest {
|
||||
}
|
||||
|
||||
export interface CompatibilityLiveRequest {
|
||||
project_name: string;
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export async function runAutomaticSpatialSourceStart(
|
||||
start: () => Promise<boolean>,
|
||||
activateAutomaticSpatialSource: () => void,
|
||||
openSpatialScene: () => void,
|
||||
): Promise<boolean> {
|
||||
const started = await start();
|
||||
if (!started) return false;
|
||||
activateAutomaticSpatialSource();
|
||||
openSpatialScene();
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
isSourceRuntimeBusy,
|
||||
isVendorWriteCapable,
|
||||
recoverableAcquisition,
|
||||
sourceStatusLabel,
|
||||
} from "../lifecycle";
|
||||
import { normalizeProjectName, validateProjectName } from "../projectName";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
@@ -30,10 +33,12 @@ export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
profileConfirmed,
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
profileConfirmed: boolean;
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
@@ -44,10 +49,18 @@ export function K1AcquisitionPipeline({
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectNameTouched, setProjectNameTouched] = useState(false);
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const projectNameValidation = validateProjectName(projectName);
|
||||
const vendorWriteCapable = isVendorWriteCapable(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
@@ -57,9 +70,15 @@ export function K1AcquisitionPipeline({
|
||||
}
|
||||
}, [state?.acquisition?.state, state?.source_mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
|
||||
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
|
||||
hydratedAcquisitionId.current = acquisitionId;
|
||||
setProjectName(preparedAcquisition?.project_name ?? "");
|
||||
setProjectNameTouched(false);
|
||||
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
|
||||
|
||||
const isBusy = pendingAction !== null;
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
|
||||
const effectiveSessionIntent: SessionIntent =
|
||||
@@ -85,31 +104,39 @@ export function K1AcquisitionPipeline({
|
||||
);
|
||||
|
||||
const submitLive = async () => {
|
||||
if (!profileConfirmed || sourceRuntimeBusy) return;
|
||||
setProjectNameTouched(true);
|
||||
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
|
||||
const targetHost = liveHost.trim();
|
||||
const started = await prepareAndStartAcquisition({
|
||||
...(targetHost ? { host: targetHost } : {}),
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
});
|
||||
if (started) openSpatialScene();
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => prepareAndStartAcquisition({
|
||||
project_name: projectNameValidation.value,
|
||||
...(targetHost ? { host: targetHost } : {}),
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
}),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) openSpatialScene();
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
}),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
|
||||
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
|
||||
</header>
|
||||
@@ -121,27 +148,51 @@ export function K1AcquisitionPipeline({
|
||||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Обязательное поле · до 96 символов"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
setProjectNameTouched(true);
|
||||
}}
|
||||
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
||||
disabled={sessionLocked}
|
||||
autoComplete="off"
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
|
||||
placeholder="Например, Испытание маршрута 01"
|
||||
/>
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
disabled={sessionLocked}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !profileConfirmed || !liveTargetReady || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
|
||||
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Подготавливаем приём…" : preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить приём данных"}
|
||||
{pendingAction === "live"
|
||||
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
|
||||
: vendorWriteCapable
|
||||
? "Инициировать приём данных и работу устройства"
|
||||
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
|
||||
: liveTargetReady
|
||||
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
|
||||
? vendorWriteCapable
|
||||
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
|
||||
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -163,7 +214,9 @@ export function K1AcquisitionPipeline({
|
||||
{state?.source_mode === "replay"
|
||||
? "Остановка завершит фактически запущенный повтор записи."
|
||||
: activeAcquisition || state?.source_mode === "live"
|
||||
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
|
||||
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
|
||||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type { AcquisitionState, XgridsAcquisition } from "../api";
|
||||
import {
|
||||
isSoftwareCommandedAcquisition,
|
||||
shouldRenderSpatialControls,
|
||||
} from "../lifecycle";
|
||||
import {
|
||||
deviceTelemetry,
|
||||
formatNumber,
|
||||
spatialActionFailure,
|
||||
} from "../presentation";
|
||||
import { useXgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function phasePresentation(
|
||||
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: "Ожидание запуска на устройстве",
|
||||
detail: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "Калибровка оборудования"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Статическая инициализация после запуска — не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
acquiring: {
|
||||
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
|
||||
detail: softwareCommanded
|
||||
? "Состояние получено из профилированного контура управления K1."
|
||||
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: "Ожидание остановки на устройстве",
|
||||
detail: "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 K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop } = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const softwareCommanded = isSoftwareCommandedAcquisition(state);
|
||||
const phase = phasePresentation(acquisition, softwareCommanded);
|
||||
const telemetry = deviceTelemetry(state.metrics);
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition.state,
|
||||
);
|
||||
const stopDisabled = pendingAction !== null || stopping;
|
||||
const actionFailure = spatialActionFailure(
|
||||
controller.error ??
|
||||
(cleanupPending
|
||||
? "Локальный поток или архив ещё не завершён. Повторите остановку."
|
||||
: null),
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : 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 ? (
|
||||
<div className="xgrids-k1-spatial-controls__error" role="alert">
|
||||
<strong>{actionFailure.title}</strong>
|
||||
<small>{actionFailure.detail}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={stopDisabled}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
|
||||
: stopping
|
||||
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
|
||||
: actionFailure
|
||||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,17 @@ export function isTerminalAcquisitionState(
|
||||
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
|
||||
}
|
||||
|
||||
export function shouldRenderSpatialControls(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || state?.source_mode === "replay") return false;
|
||||
return (
|
||||
!isTerminalAcquisitionState(acquisition.state) ||
|
||||
acquisition.cleanup_pending === true
|
||||
);
|
||||
}
|
||||
|
||||
export function recoverableAcquisition(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): XgridsAcquisition | null {
|
||||
@@ -44,6 +55,21 @@ export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): bo
|
||||
return state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
}
|
||||
|
||||
export function isVendorWriteCapable(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
state?.compatibility?.vendor_writes_enabled === true &&
|
||||
state.compatibility.permitted_mode === "active-control"
|
||||
);
|
||||
}
|
||||
|
||||
export function isSoftwareCommandedAcquisition(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
return isVendorWriteCapable(state) && state?.acquisition?.control_mode === "plugin-commanded";
|
||||
}
|
||||
|
||||
export function confirmedRuntimeSourceMode(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): RuntimeSourceMode {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
||||
import { XgridsK1Connection } from "./XgridsK1Connection";
|
||||
import { K1SpatialControls } from "./components/K1SpatialControls";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import { XgridsK1RuntimeProvider } from "./runtimeContext";
|
||||
import "./styles.css";
|
||||
@@ -7,6 +8,7 @@ import "./styles.css";
|
||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||
manifest: xgridsK1Manifest,
|
||||
RuntimeProvider: XgridsK1RuntimeProvider,
|
||||
SpatialControlsView: K1SpatialControls,
|
||||
connectionViews: Object.freeze({
|
||||
"xgrids-k1.connection": XgridsK1Connection,
|
||||
}),
|
||||
|
||||
@@ -69,6 +69,52 @@ 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;
|
||||
}
|
||||
|
||||
export function spatialActionFailure(
|
||||
error: string | null | undefined,
|
||||
): SpatialActionFailure | null {
|
||||
const detail = error?.trim();
|
||||
return detail
|
||||
? {
|
||||
title: "Действие K1 не выполнено",
|
||||
detail,
|
||||
}
|
||||
: 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", {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const K1_PROJECT_NAME_MAX_LENGTH = 96;
|
||||
|
||||
export interface ProjectNameValidation {
|
||||
value: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const CONTROL_CHARACTER_OR_SURROGATE = /[\p{Cc}\p{Cs}]/u;
|
||||
|
||||
export function normalizeProjectName(input: string): string {
|
||||
return input.normalize("NFKC").trim();
|
||||
}
|
||||
|
||||
export function validateProjectName(input: string): ProjectNameValidation {
|
||||
const value = normalizeProjectName(input);
|
||||
if (!value) {
|
||||
return { value, error: "Введите название проекта." };
|
||||
}
|
||||
if (CONTROL_CHARACTER_OR_SURROGATE.test(value)) {
|
||||
return {
|
||||
value,
|
||||
error: "Название проекта не должно содержать управляющие символы.",
|
||||
};
|
||||
}
|
||||
if (Array.from(value).length > K1_PROJECT_NAME_MAX_LENGTH) {
|
||||
return {
|
||||
value,
|
||||
error: `Название проекта должно быть не длиннее ${K1_PROJECT_NAME_MAX_LENGTH} символов.`,
|
||||
};
|
||||
}
|
||||
return { value, error: null };
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import { finiteMetric, pipelineLatency } from "./presentation";
|
||||
import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
|
||||
import { xgridsK1ObservationSources } from "./observationSources";
|
||||
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
|
||||
|
||||
@@ -30,6 +30,7 @@ function normalizeState(
|
||||
const state = controller.state;
|
||||
if (!state) return null;
|
||||
const metrics = state.metrics;
|
||||
const telemetry = deviceTelemetry(metrics);
|
||||
const deviceRef = state.device_ref;
|
||||
const deviceSession = state.device_session;
|
||||
const acquisition = effectiveAcquisition(state);
|
||||
@@ -69,6 +70,7 @@ function normalizeState(
|
||||
state: acquisition.state,
|
||||
stateRevision: acquisition.state_revision,
|
||||
operatorInstructions: acquisition.operator_instructions ?? [],
|
||||
cleanupPending: acquisition.cleanup_pending === true,
|
||||
}
|
||||
: null,
|
||||
operations: (state.operations ?? []).map((operation) => ({
|
||||
@@ -106,6 +108,9 @@ function normalizeState(
|
||||
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
|
||||
pointCount: finiteMetric(metrics?.point_count),
|
||||
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
|
||||
elapsedSeconds: telemetry.elapsedSeconds,
|
||||
routeDistanceMeters: telemetry.routeDistanceMeters,
|
||||
speedMetersPerSecond: telemetry.speedMetersPerSecond,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -464,3 +464,115 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.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__spinner {
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
flex: 0 0 0.82rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: xgrids-k1-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes xgrids-k1-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
liveStartPlan,
|
||||
operationByIdempotencyKey,
|
||||
operationNeedsReconciliation,
|
||||
isSoftwareCommandedAcquisition,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
@@ -65,6 +66,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
const actionInFlight = useRef(false);
|
||||
|
||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||
@@ -101,6 +103,8 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||
if (!enabled) return false;
|
||||
if (actionInFlight.current) return false;
|
||||
actionInFlight.current = true;
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
@@ -117,6 +121,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
actionInFlight.current = false;
|
||||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
@@ -202,13 +207,13 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
if (acquisition && !acquisitionTerminal) {
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "capture-only",
|
||||
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
|
||||
});
|
||||
}
|
||||
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
|
||||
return xgridsK1Api.stopSessionCompatibility();
|
||||
}),
|
||||
[run, state?.acquisition],
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user