fix(k1): simplify response-gated launch UX

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 18:17:16 +03:00
parent 7b7b5d6cad
commit d7a2c22faf
10 changed files with 306 additions and 161 deletions
@@ -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,