fix(k1): simplify response-gated launch UX
This commit is contained in:
@@ -53,11 +53,8 @@ export function K1AcquisitionPipeline({
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
openApplicationControlSession,
|
||||
enterApplicationWorkspace,
|
||||
closeApplicationControlSession,
|
||||
prepareAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startCanonicalAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
@@ -66,7 +63,6 @@ export function K1AcquisitionPipeline({
|
||||
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);
|
||||
@@ -105,7 +101,6 @@ export function K1AcquisitionPipeline({
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host);
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const sourceTone: StatusTone =
|
||||
@@ -120,30 +115,37 @@ export function K1AcquisitionPipeline({
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
|
||||
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 preparedCanonicalLaunch =
|
||||
preparedAcquisition?.control_mode === "plugin-commanded";
|
||||
const launchBlockedByAcquisition =
|
||||
activeAcquisition !== null && !preparedCanonicalLaunch;
|
||||
const controlRetryBlocked =
|
||||
controlPhase === "failed" && control?.can_open !== true;
|
||||
|
||||
const startLive = async () => {
|
||||
setProjectNameTouched(true);
|
||||
if (
|
||||
!profileConfirmed ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked ||
|
||||
projectNameValidation.error
|
||||
) return;
|
||||
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
|
||||
() => startCanonicalAcquisition({
|
||||
control: {
|
||||
...PHYSICAL_ACCEPTANCE,
|
||||
timezone_name: timezoneName,
|
||||
},
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
},
|
||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||
}),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
@@ -179,96 +181,64 @@ export function K1AcquisitionPipeline({
|
||||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
{(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 ? (
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Имя войдёт в единственный канонический START"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
setProjectNameTouched(true);
|
||||
}}
|
||||
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
||||
disabled={isBusy || preparedAcquisition !== null || sourceRuntimeBusy}
|
||||
autoComplete="off"
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Checker
|
||||
checked={physicalAcceptanceConfirmed}
|
||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||
onChange={setPhysicalAcceptanceConfirmed}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked
|
||||
}
|
||||
onClick={() => void startLive()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? controlPhase === "connecting"
|
||||
? "Синхронизация с K1…"
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Входим в рабочее пространство…"
|
||||
: controlPhase === "project-requested"
|
||||
? "Готовим проект и локальный приём…"
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования…"
|
||||
: "Запускаем K1 и локальный приём…"
|
||||
: preparedCanonicalLaunch
|
||||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
</Button>
|
||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
onClick={() => void closeApplicationControlSession()}
|
||||
>
|
||||
Закрыть управляющую сессию без START
|
||||
Отменить запуск до START
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
@@ -277,16 +247,16 @@ export function K1AcquisitionPipeline({
|
||||
: controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Выполняется только операция входа в рабочее пространство."
|
||||
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
|
||||
: controlPhase === "project-requested"
|
||||
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
|
||||
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
|
||||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type OperatorPresenceConfirmation,
|
||||
type PrepareAcquisitionRequest,
|
||||
type ReplayRequest,
|
||||
type XgridsApplicationControlPhase,
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
@@ -35,6 +36,50 @@ export type PendingAction =
|
||||
| "camera"
|
||||
| "viewer";
|
||||
|
||||
export interface CanonicalLiveStartRequest {
|
||||
control: OpenApplicationControlSessionRequest;
|
||||
acquisition: PrepareAcquisitionRequest;
|
||||
physicalAcceptance: OperatorPresenceConfirmation;
|
||||
}
|
||||
|
||||
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
||||
|
||||
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||
return state.application_control_session?.state ?? "idle";
|
||||
}
|
||||
|
||||
function controlFailure(state: XgridsK1State): ApiError {
|
||||
const failure = state.application_control_session?.failure;
|
||||
return new ApiError(
|
||||
failure?.message
|
||||
? `Канонический диалог K1 остановлен: ${failure.message}`
|
||||
: "Канонический диалог K1 остановлен до запуска сканирования.",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForControlPhase(
|
||||
expected: XgridsApplicationControlPhase,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
): Promise<XgridsK1State> {
|
||||
for (;;) {
|
||||
const nextState = await xgridsK1Api.getState();
|
||||
acceptState(nextState);
|
||||
const phase = controlPhase(nextState);
|
||||
if (phase === expected) return nextState;
|
||||
if (phase === "failed") throw controlFailure(nextState);
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
throw new ApiError(
|
||||
`Управляющая сессия K1 завершилась до ожидаемого этапа «${expected}».`,
|
||||
);
|
||||
}
|
||||
// This cadence only reads local server state. It never schedules, retries,
|
||||
// or times a K1 command; every next write remains gated by device response.
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
@@ -189,6 +234,102 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
[run],
|
||||
);
|
||||
|
||||
const startCanonicalAcquisition = useCallback(
|
||||
(request: CanonicalLiveStartRequest) =>
|
||||
run("live", async () => {
|
||||
let nextState = await xgridsK1Api.getState();
|
||||
acceptState(nextState);
|
||||
const plan = liveStartPlan(nextState);
|
||||
if (plan === "blocked") {
|
||||
throw new ApiError("Сначала завершите текущий приём или повтор записи.");
|
||||
}
|
||||
if (plan === "already-running") return nextState;
|
||||
|
||||
for (;;) {
|
||||
const phase = controlPhase(nextState);
|
||||
const acquisition = nextState.acquisition;
|
||||
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
||||
throw new ApiError(
|
||||
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
||||
);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "failed") {
|
||||
if (nextState.application_control_session?.can_open !== true) {
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connecting") {
|
||||
nextState = await waitForControlPhase("connection-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connection-ready") {
|
||||
nextState = await xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
});
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-requested") {
|
||||
nextState = await waitForControlPhase("workspace-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-ready") {
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
if (acquisition.state !== "prepared" || acquisition.control_mode !== "plugin-commanded") {
|
||||
throw new ApiError(
|
||||
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
||||
);
|
||||
}
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "project-requested") {
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "project-ready") {
|
||||
if (!acquisition || acquisition.state !== "prepared") {
|
||||
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
||||
}
|
||||
nextState = await xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
});
|
||||
acceptState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (["start-requested", "initializing", "scanning"].includes(phase)) {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
throw new ApiError(`Запуск K1 недоступен из состояния «${phase}».`);
|
||||
}
|
||||
}),
|
||||
[acceptState, run],
|
||||
);
|
||||
|
||||
const prepareAcquisition = useCallback(
|
||||
(request: PrepareAcquisitionRequest) =>
|
||||
run("live", async () => {
|
||||
@@ -417,6 +558,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
openApplicationControlSession,
|
||||
enterApplicationWorkspace,
|
||||
closeApplicationControlSession,
|
||||
startCanonicalAcquisition,
|
||||
prepareAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
|
||||
Reference in New Issue
Block a user