feat(k1): wire canonical control session to UI

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 17:53:55 +03:00
parent 36f0c93d2a
commit 7b7b5d6cad
24 changed files with 1591 additions and 184 deletions
+77
View File
@@ -104,6 +104,47 @@ export interface XgridsApplicationControlExecution {
can_emit_requests: false;
}
export type XgridsApplicationControlPhase =
| "idle"
| "connecting"
| "connection-ready"
| "workspace-requested"
| "workspace-ready"
| "project-requested"
| "project-ready"
| "start-requested"
| "initializing"
| "scanning"
| "stop-requested"
| "stopping"
| "awaiting-standby-confirmation"
| "completed"
| "closed"
| "failed";
export interface XgridsApplicationControlSession {
mode: "interactive-canonical";
state: XgridsApplicationControlPhase;
control_socket_open: boolean;
can_open: boolean;
can_enter_workspace: boolean;
can_prepare_project: boolean;
can_start: boolean;
can_stop: boolean;
can_confirm_standby: boolean;
pending_operator_action?: string | null;
scripted_transitions: false;
automatic_retry: false;
outcome_unknown: boolean;
failure?: {
code?: string;
message?: string;
safe_to_retry?: boolean;
} | null;
dialogue?: Record<string, unknown> | null;
transport?: Record<string, unknown> | null;
}
export interface XgridsAcquisition {
schema_version?: string;
acquisition_id: string;
@@ -228,6 +269,7 @@ export interface XgridsK1State {
compatibility?: XgridsCompatibilityState | null;
modeling_control_safety?: XgridsModelingControlSafety | null;
application_control_execution?: XgridsApplicationControlExecution | null;
application_control_session?: XgridsApplicationControlSession | null;
device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null;
acquisition?: XgridsAcquisition | null;
@@ -288,6 +330,7 @@ export interface StartAcquisitionRequest {
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
}
export interface StopAcquisitionRequest {
@@ -297,6 +340,7 @@ export interface StopAcquisitionRequest {
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
}
export interface AbortAcquisitionRequest {
@@ -335,6 +379,23 @@ export interface ShadowApplicationControlArmRequest {
timezone_name: string;
}
export interface OperatorPresenceConfirmation {
operator_present: true;
owner_controlled_device: true;
lixelgo_closed: true;
battery_storage_confirmed: true;
expected_physical_state_confirmed: true;
}
export interface OpenApplicationControlSessionRequest
extends OperatorPresenceConfirmation {
timezone_name: string;
}
export interface EnterApplicationWorkspaceRequest {
operator_confirmed: true;
}
export class ApiError extends Error {
readonly status: number;
@@ -496,6 +557,22 @@ export const xgridsK1Api = {
disarmShadowApplicationControl(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowDisarm);
},
openApplicationControlSession(
body: OpenApplicationControlSessionRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlSessionOpen, body);
},
enterApplicationWorkspace(
body: EnterApplicationWorkspaceRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body);
},
closeApplicationControlSession(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlSessionClose);
},
};
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
@@ -14,6 +14,7 @@ import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
isConfirmedLiveState,
isSoftwareCommandedAcquisition,
isSourceRuntimeBusy,
isVendorWriteCapable,
recoverableAcquisition,
@@ -21,6 +22,7 @@ import {
} from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
import type { OperatorPresenceConfirmation } from "../api";
type SessionIntent = "live" | "replay";
@@ -29,6 +31,14 @@ const sessionItems = [
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
export function K1AcquisitionPipeline({
controller,
profileConfirmed,
@@ -43,9 +53,14 @@ export function K1AcquisitionPipeline({
const {
state,
pendingAction,
prepareAndStartAcquisition,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@@ -55,12 +70,15 @@ export function K1AcquisitionPipeline({
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = 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);
const control = state?.application_control_session;
const controlPhase = control?.state ?? "idle";
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -103,16 +121,29 @@ export function K1AcquisitionPipeline({
[sessionLocked],
);
const submitLive = async () => {
const openControl = async () => {
if (!physicalAcceptanceConfirmed || !state?.k1_ip) return;
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
await openApplicationControlSession({
...PHYSICAL_ACCEPTANCE,
timezone_name: timezoneName,
});
};
const prepareProject = async () => {
setProjectNameTouched(true);
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim();
await prepareAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
};
const startLive = async () => {
await runAutomaticSpatialSourceStart(
() => prepareAndStartAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
}),
() => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
activateAutomaticSpatialSource,
openSpatialScene,
);
@@ -148,52 +179,114 @@ 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 || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
onClick={() => void submitLive()}
>
{pendingAction === "live"
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
: vendorWriteCapable
? "Инициировать приём данных и работу устройства"
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
</Button>
{(control?.can_open ?? controlPhase === "idle") ? (
<>
<Checker
checked={physicalAcceptanceConfirmed}
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
onChange={setPhysicalAcceptanceConfirmed}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed}
onClick={() => void openControl()}
>
{pendingAction === "control"
? "Открываем канонический диалог…"
: "Подключить управление K1"}
</Button>
</>
) : null}
{controlPhase === "connection-ready" ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void enterApplicationWorkspace()}
>
{pendingAction === "control"
? "Открываем рабочее пространство…"
: "Открыть рабочее пространство K1"}
</Button>
) : null}
{["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? (
<>
<TextField
label="Название проекта"
hint="Будет отправлено только в отдельном START"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Сохранение имени само по себе не отправляет START на устройство."}
placeholder="Например, TEST001"
/>
<TextField
label="Адрес устройства"
hint="Тот же direct-LAN адрес, который вернул выбранный K1"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
</>
) : null}
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
<Button
variant="primary"
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
onClick={() => void prepareProject()}
>
{pendingAction === "live"
? "Сохраняем проект и готовим приём…"
: "Сохранить проект и подготовить локальный приём"}
</Button>
) : null}
{controlPhase === "project-ready" && preparedAcquisition ? (
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !physicalAcceptanceConfirmed}
onClick={() => void startLive()}
>
{pendingAction === "live"
? "Отправляем один канонический START…"
: "Запустить сканирование K1"}
</Button>
) : null}
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void closeApplicationControlSession()}
>
Закрыть управляющую сессию без START
</Button>
) : null}
<p className="live-instruction">
{!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? vendorWriteCapable
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
: controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
: controlPhase === "connecting"
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
: controlPhase === "workspace-requested"
? "Выполняется только операция входа в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
</p>
</div>
) : (
@@ -219,7 +312,22 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>
{control?.can_confirm_standby ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный — завершить запись
</Button>
) : null}
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop(
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
)}
>
{pendingAction === "stop"
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
@@ -1,7 +1,11 @@
import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type { AcquisitionState, XgridsAcquisition } from "../api";
import type {
AcquisitionState,
OperatorPresenceConfirmation,
XgridsAcquisition,
} from "../api";
import {
isSoftwareCommandedAcquisition,
shouldRenderSpatialControls,
@@ -19,6 +23,14 @@ interface PhasePresentation {
busy: boolean;
}
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
function phasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
@@ -58,8 +70,10 @@ function phasePresentation(
busy: false,
},
awaiting_external_stop: {
label: "Ожидание остановки на устройстве",
detail: "Mission Core ждёт подтверждения физической остановки K1.",
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
@@ -94,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller;
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
@@ -108,8 +122,14 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
acquisition.state,
);
const stopDisabled = pendingAction !== null || stopping;
const controlFailure =
state.application_control_session?.state === "failed"
? state.application_control_session.failure?.message ||
"Канонический диалог остановлен; автоматический повтор запрещён."
: null;
const actionFailure = spatialActionFailure(
controller.error ??
controlFailure ??
(cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку."
: null),
@@ -149,7 +169,7 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop()}
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
@@ -159,6 +179,16 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный — завершить запись
</Button>
) : null}
</section>
);
}
@@ -42,4 +42,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"application-control.shadow-disarm",
),
applicationControlSessionOpen: requirePluginAction(
xgridsK1Manifest,
"application-control.session.open",
),
applicationControlWorkspaceEnter: requirePluginAction(
xgridsK1Manifest,
"application-control.workspace.enter",
),
applicationControlSessionClose: requirePluginAction(
xgridsK1Manifest,
"application-control.session.close",
),
});
@@ -140,13 +140,17 @@ export function XgridsK1RuntimeProvider({
if (!activeModel) return;
return registerDeactivation(async () => {
if (controller.pendingAction !== null) return false;
// Always ask the backend to stop. The browser snapshot may be stale or not
// loaded yet, while a previous local capture is still alive.
// Never translate navigation/model switching into a K1 control command.
// The operator must explicitly finish or close the canonical dialogue.
if (controller.state?.application_control_session?.control_socket_open) {
return false;
}
return controller.stop();
});
}, [
activeModel,
controller.pendingAction,
controller.state?.application_control_session?.control_socket_open,
controller.stop,
registerDeactivation,
]);
@@ -8,6 +8,8 @@ import {
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
@@ -25,6 +27,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "control"
| "live"
| "replay"
| "stop"
@@ -167,7 +170,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state],
);
const prepareAndStartAcquisition = useCallback(
const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
[run],
);
const enterApplicationWorkspace = useCallback(
() =>
run("control", () =>
xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }),
),
[run],
);
const closeApplicationControlSession = useCallback(
() => run("control", () => xgridsK1Api.closeApplicationControlSession()),
[run],
);
const prepareAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
@@ -178,17 +200,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
}
if (plan === "already-running" && state) return state;
const prepared =
return (
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
: await xgridsK1Api.prepareAcquisition(request)
);
}),
[run, state],
);
const startPreparedAcquisition = useCallback(
(physicalAcceptance: OperatorPresenceConfirmation) =>
run("live", async () => {
const acquisition = state?.acquisition;
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: physicalAcceptance,
});
}),
[run, state],
@@ -200,22 +231,57 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
const stop = useCallback(
() =>
(physicalAcceptance?: OperatorPresenceConfirmation) =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
const softwareCommanded = isSoftwareCommandedAcquisition(state);
if (softwareCommanded && !physicalAcceptance) {
throw new ApiError(
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
);
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
mode: softwareCommanded ? "graceful" : "capture-only",
...(physicalAcceptance
? { physical_acceptance: physicalAcceptance }
: {}),
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state],
);
const confirmStoppedAtSteadyGreen = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
}
const stopOperation = [...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === "acquisition.stop" &&
operation.status === "operator_action_required",
);
if (!stopOperation) {
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "graceful",
operator_confirmed: true,
operation_id: stopOperation.operation_id,
});
}),
[run, state],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@@ -348,9 +414,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null),
scan,
connect,
prepareAndStartAcquisition,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
setObservationSourceActive,
updateViewerSettings,