feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user