feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions
@@ -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>
+12
View File
@@ -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 {
+2
View File
@@ -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,
},
};
}
+112
View File
@@ -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(() => {