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
+10 -8
View File
@@ -32,9 +32,9 @@ The plugin owns:
- K1-specific operator instructions and compatibility tests.
- the scoped React `device.connection` contribution and its BLE/Wi-Fi and
acquisition pipeline UI.
- the interactive canonical application-control session: one socket owner,
separate workspace/project/START/STOP UI checkpoints, live status gates,
single-action physical permits and no automatic retry.
- the interactive canonical application-control session: one socket owner, one
operator launch intent, response-gated workspace/project/START stages, a
separate STOP confirmation, live status gates and no automatic retry.
The plugin does not own:
@@ -85,11 +85,13 @@ uv run python plugins/xgrids-k1/profile_loader.py
```
Plugin v0.5.0 installs that reviewed transport behind explicit plugin actions.
Opening control performs only the connection-owned operations 16. Workspace
entry releases operation 7; saving the project and preparing local reception
releases operations 810; a separate START click carries the project name and
then waits for bound `SCANNING + project + init_ready` before operations 1314.
STOP is separately permitted, never retried, and keeps the same socket until K1
The normal UI accepts the project name and one explicit launch confirmation.
It then performs operations 16, operation 7 and operations 810 only after the
previous response barrier succeeds, prepares local reception, and emits one
START carrying the project name. Local state polling controls only when the
next reviewed action may be requested; it never schedules a device command by
elapsed time. START waits for bound `SCANNING + project + init_ready` before
operations 1314. STOP is separately permitted, never retried, and keeps the same socket until K1
reports unbound READY and the operator confirms a steady green indicator. The
admin CLI still provisions the fixed Keychain item through Apple's hidden
prompt without accepting the private value as an argument. No physical command
@@ -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,