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

View File

@ -156,9 +156,10 @@ physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
keepalive 60, exact keepalive 60, exact
response subscriptions, QoS2 completion and the five response-gated batches. response subscriptions, QoS2 completion and the five response-gated batches.
It consumes operation keys before publish and poisons unknown outcomes without It consumes operation keys before publish and poisons unknown outcomes without
retry. One background owner services that socket between separate connection, retry. One background owner services that socket through response-gated
workspace, project, START, STOP and steady-green UI actions; navigation cannot connection, workspace, project, START, STOP and steady-green stages. The normal
emit a command. The first operator-present physical attempt on 2026-07-18 emitted only UI collapses only the pre-START controls into one explicit operator intent;
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
the first six bootstrap requests, then failed closed while correlating the third the first six bootstrap requests, then failed closed while correlating the third
response batch; START was not emitted. A second one-shot attempt correlated all response batch; START was not emitted. A second one-shot attempt correlated all
ten pre-START requests and the START acknowledgement, but incorrectly collapsed ten pre-START requests and the START acknowledgement, but incorrectly collapsed
@ -172,11 +173,10 @@ immediate status read 12 and state-gated reads 1314 onto one continuously own
MQTT session. No captured operator delay is a protocol timer: the final reads MQTT session. No captured operator delay is a protocol timer: the final reads
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
for live `READY` plus physical standby confirmation. Standalone START and STOP for live `READY` plus physical standby confirmation. Standalone START and STOP
are both rejected. The same socket is serviced during every operator/UI wait, are both rejected. The same socket is serviced throughout launch and STOP
and the short START/STOP permits are created only at their respective button confirmation, and no captured human delay is replayed as a protocol timer.
confirmations rather than at connection time. This executor remains uninstalled and is not wired to the Plugin v0.5.0 wires this executor to the facade and plugin UI; physical command
facade/UI; physical command acceptance is still paused. The temporary laboratory acceptance remains an operator-run gate.
Keychain item was deleted.
This locked bootstrap is repeatable in the current workspace, not yet a This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from standalone release install. The frontend consumes sibling `file:` packages from

View File

@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented | | Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet | | Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | OPERATOR ACCEPTANCE READY — the failed collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner behind separate connection, workspace, project, START, STOP and steady-green UI actions. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open | | K1 application control | OPERATOR ACCEPTANCE READY — the failed protocol-collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner; one explicit UI launch intent advances connection, workspace, project and START only across live response barriers, while STOP and steady-green confirmation remain separate. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the USB project copying remains optional ground truth rather than a blocker for the

View File

@ -115,23 +115,24 @@ directly and use their sibling metadata receive timestamps when present.
complete visible-device list. complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed 4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically. provisioning write. The backend does not retry the write automatically.
5. Confirm operator presence, closed LixelGO, storage/power and steady green; 5. Enter the required project name, confirm operator presence, closed LixelGO,
choose **Подключить управление K1**. This emits only connection operations storage/power and steady green, then choose **Запустить сканирование и
16 and then waits indefinitely for the next UI action. локальный приём** once.
6. Choose **Открыть рабочее пространство K1** for operation 7. 6. Mission Core emits operations 16, waits for their correlated device
7. Enter the required project name and choose **Сохранить проект и подготовить responses, then performs operation 7 and operations 810 behind the same
локальный приём**. Mission Core normalizes it, prepares evidence reception operator intent. No captured human pause is treated as a timer and no device
and releases only operations 810; the name itself is not sent yet. command is automatically retried.
8. After the project step is ready, choose **Запустить сканирование K1** once. 7. Mission Core prepares evidence reception before emitting the single
The project name is carried by canonical START. Mission Core opens canonical START. The project name is carried only by that START; there is no
separate K1 project-name write. Mission Core opens
**Наблюдение → Пространственная сцена** and shows calibration while the same **Наблюдение → Пространственная сцена** and shows calibration while the same
control socket waits for `SCANNING + project + init_ready`. control socket waits for `SCANNING + project + init_ready`.
9. Real point frames and pose/trajectory updates then appear in the embedded 8. Real point frames and pose/trajectory updates then appear in the embedded
viewport. Do not use the physical button or open LixelGO during this cycle. viewport. Do not use the physical button or open LixelGO during this cycle.
10. When K1 emits `ModelingReport`, the same plugin block shows its current scan 9. When K1 emits `ModelingReport`, the same plugin block shows its current scan
time, route distance and speed. These are device reports, not values inferred time, route distance and speed. These are device reports, not values inferred
from a browser timer or accumulated across a device counter reset. from a browser timer or accumulated across a device counter reset.
11. Choose **Остановить устройство и запись** in the scene. The 10. Choose **Остановить устройство и запись** in the scene. The
plugin-commanded path emits one canonical STOP on the original socket, plugin-commanded path emits one canonical STOP on the original socket,
waits for live unbound READY, and asks the operator to confirm the constant waits for live unbound READY, and asks the operator to confirm the constant
green indicator before capture, camera archive and summaries are finalized. green indicator before capture, camera archive and summaries are finalized.
@ -142,7 +143,8 @@ per-message metadata and a hash summary. Repository-level
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by `sessions/*_viewer_live` is legacy import-only evidence and is never selected by
the current writer. The data connector remains subscribe-only. Application the current writer. The data connector remains subscribe-only. Application
requests are isolated in the separate canonical control-session owner and can requests are isolated in the separate canonical control-session owner and can
be emitted only by explicit staged UI actions. be emitted only after the explicit launch or STOP intent and the corresponding
live response gate.
The recovered command substrate byte-matches the ten The recovered command substrate byte-matches the ten
observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
@ -151,8 +153,8 @@ private application-level OpenAPI authority is kept
separate from the transient vendor ID/serial returned by the BLE-selected K1. separate from the transient vendor ID/serial returned by the BLE-selected K1.
The fixed Keychain loader and dormant shadow coordinator remain available for The fixed Keychain loader and dormant shadow coordinator remain available for
inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT
owner: operations 16 at connection, 7 at workspace entry, 810 at project owner: one UI launch intent advances operations 16, 7, 810 and START through
preparation, then separately permitted START and STOP. Physical START/STOP and their live response barriers; STOP remains a separate explicit action. Physical START/STOP and
durable native-project completion still require operator acceptance. durable native-project completion still require operator acceptance.
## Automatic Rerun source and lifecycle ## Automatic Rerun source and lifecycle

View File

@ -138,9 +138,12 @@ connection time.
Plugin v0.5.0 installs this boundary through Plugin v0.5.0 installs this boundary through
`application_session.py`. One background owner is the only thread allowed to `application_session.py`. One background owner is the only thread allowed to
touch the control MQTT client. REST/UI actions release exactly one checkpoint: touch the control MQTT client. The standard UI turns one explicit launch intent
session open (16), workspace entry (7), project preparation (810), START, STOP into response-gated REST actions for session open (16), workspace entry (7),
and steady-green confirmation. Preparing local reception happens before the project preparation (810) and START. This is control compression, not protocol
compression: each next action waits for the exact server/device phase, and
local polling never retries or times a K1 write. STOP and steady-green
confirmation remain separate operator actions. Preparing local reception happens before the
START event is released. After STOP, local capture remains owned until live START event is released. After STOP, local capture remains owned until live
unbound READY and explicit visual confirmation. Page close, model switching, unbound READY and explicit visual confirmation. Page close, model switching,
polling and service state reads cannot emit START/STOP; model switching is polling and service state reads cannot emit START/STOP; model switching is

View File

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

View File

@ -53,11 +53,8 @@ export function K1AcquisitionPipeline({
const { const {
state, state,
pendingAction, pendingAction,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession, closeApplicationControlSession,
prepareAcquisition, startCanonicalAcquisition,
startPreparedAcquisition,
startReplay, startReplay,
stop, stop,
confirmStoppedAtSteadyGreen, confirmStoppedAtSteadyGreen,
@ -66,7 +63,6 @@ export function K1AcquisitionPipeline({
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live"); const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
const [projectNameTouched, setProjectNameTouched] = useState(false); const [projectNameTouched, setProjectNameTouched] = useState(false);
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState(""); const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1"); const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false); const [replayLoop, setReplayLoop] = useState(false);
@ -105,7 +101,6 @@ export function K1AcquisitionPipeline({
: activeAcquisition : activeAcquisition
? "live" ? "live"
: sessionIntent; : sessionIntent;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host);
const sourceLabel = sourceStatusLabel(state); const sourceLabel = sourceStatusLabel(state);
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed"; const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
const sourceTone: StatusTone = const sourceTone: StatusTone =
@ -120,30 +115,37 @@ export function K1AcquisitionPipeline({
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })), () => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked], [sessionLocked],
); );
const preparedCanonicalLaunch =
const openControl = async () => { preparedAcquisition?.control_mode === "plugin-commanded";
if (!physicalAcceptanceConfirmed || !state?.k1_ip) return; const launchBlockedByAcquisition =
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"; activeAcquisition !== null && !preparedCanonicalLaunch;
await openApplicationControlSession({ const controlRetryBlocked =
...PHYSICAL_ACCEPTANCE, controlPhase === "failed" && control?.can_open !== true;
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 () => { 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( 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, activateAutomaticSpatialSource,
openSpatialScene, openSpatialScene,
); );
@ -179,8 +181,23 @@ export function K1AcquisitionPipeline({
/> />
{effectiveSessionIntent === "live" ? ( {effectiveSessionIntent === "live" ? (
<div className="session-form"> <div className="session-form">
{(control?.can_open ?? controlPhase === "idle") ? ( <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 <Checker
checked={physicalAcceptanceConfirmed} checked={physicalAcceptanceConfirmed}
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный" label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
@ -189,86 +206,39 @@ export function K1AcquisitionPipeline({
<Button <Button
variant="primary" variant="primary"
icon={<Icon name="activity" />} icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed} disabled={
onClick={() => void openControl()} isBusy ||
> !profileConfirmed ||
{pendingAction === "control" !state?.k1_ip ||
? "Открываем канонический диалог…" !physicalAcceptanceConfirmed ||
: "Подключить управление K1"} projectNameValidation.error !== null ||
</Button> sourceRuntimeBusy ||
</> launchBlockedByAcquisition ||
) : null} controlRetryBlocked
{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 || "Сначала подключите устройство к WiFi"}
/>
</>
) : 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()} onClick={() => void startLive()}
> >
{pendingAction === "live" {pendingAction === "live"
? "Отправляем один канонический START…" ? controlPhase === "connecting"
: "Запустить сканирование K1"} ? "Синхронизация с K1…"
: controlPhase === "workspace-requested"
? "Входим в рабочее пространство…"
: controlPhase === "project-requested"
? "Готовим проект и локальный приём…"
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования…"
: "Запускаем K1 и локальный приём…"
: preparedCanonicalLaunch
? "Продолжить запуск сканирования и приёма"
: "Запустить сканирование и локальный приём"}
</Button> </Button>
) : null} {control?.control_socket_open && !activeAcquisition && !isBusy ? (
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
<Button <Button
variant="ghost" variant="ghost"
disabled={isBusy} disabled={isBusy}
onClick={() => void closeApplicationControlSession()} onClick={() => void closeApplicationControlSession()}
> >
Закрыть управляющую сессию без START Отменить запуск до START
</Button> </Button>
) : null} ) : null}
<p className="live-instruction"> <p className="live-instruction">
@ -277,16 +247,16 @@ export function K1AcquisitionPipeline({
: controlPhase === "failed" : controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}` ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
: controlPhase === "connecting" : controlPhase === "connecting"
? "Выполняются только операции 16 записанного диалога. Следующий этап начнётся только по вашей кнопке." ? "Выполняются операции 16 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
: controlPhase === "workspace-requested" : controlPhase === "workspace-requested"
? "Выполняется только операция входа в рабочее пространство." ? "После подтверждённых операций 16 выполняется вход в рабочее пространство."
: controlPhase === "project-requested" : controlPhase === "project-requested"
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1." ? "Выполняются операции 810 и готовится локальный приём; имя ещё не отправляется на K1."
: controlPhase === "start-requested" || controlPhase === "initializing" : controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет." ? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
: controlPhase === "scanning" : controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене." ? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Каждый этап канонического диалога запускается отдельным действием оператора."} : "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
</p> </p>
</div> </div>
) : ( ) : (

View File

@ -12,6 +12,7 @@ import {
type OperatorPresenceConfirmation, type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest, type PrepareAcquisitionRequest,
type ReplayRequest, type ReplayRequest,
type XgridsApplicationControlPhase,
type XgridsK1State, type XgridsK1State,
} from "./api"; } from "./api";
import { import {
@ -35,6 +36,50 @@ export type PendingAction =
| "camera" | "camera"
| "viewer"; | "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 { function messageFor(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message; const message = localizeRuntimeMessage(error.message) ?? error.message;
@ -189,6 +234,102 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run], [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( const prepareAcquisition = useCallback(
(request: PrepareAcquisitionRequest) => (request: PrepareAcquisitionRequest) =>
run("live", async () => { run("live", async () => {
@ -417,6 +558,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
openApplicationControlSession, openApplicationControlSession,
enterApplicationWorkspace, enterApplicationWorkspace,
closeApplicationControlSession, closeApplicationControlSession,
startCanonicalAcquisition,
prepareAcquisition, prepareAcquisition,
startPreparedAcquisition, startPreparedAcquisition,
startReplay, startReplay,

View File

@ -220,7 +220,16 @@ class OperatorPresenceRequest(StrictRequest):
expected_physical_state_confirmed: Literal[True] expected_physical_state_confirmed: Literal[True]
def confirmation(self) -> OperatorPresenceConfirmation: def confirmation(self) -> OperatorPresenceConfirmation:
return OperatorPresenceConfirmation(**self.model_dump()) # Build the wire-safety confirmation explicitly. Subclasses add
# transport metadata (for example ``timezone_name`` on control-session
# open), which must never leak into this narrower dataclass contract.
return OperatorPresenceConfirmation(
operator_present=self.operator_present,
owner_controlled_device=self.owner_controlled_device,
lixelgo_closed=self.lixelgo_closed,
battery_storage_confirmed=self.battery_storage_confirmed,
expected_physical_state_confirmed=self.expected_physical_state_confirmed,
)
class PrepareAcquisitionRequest(OperationContextRequest): class PrepareAcquisitionRequest(OperationContextRequest):

View File

@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None: def test_xgrids_live_copy_exposes_one_response_gated_launch_intent() -> None:
repository_root = Path(__file__).resolve().parents[1] repository_root = Path(__file__).resolve().parents[1]
connection_source = ( connection_source = (
repository_root repository_root
@ -125,11 +125,14 @@ def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> Non
/ "K1AcquisitionPipeline.tsx" / "K1AcquisitionPipeline.tsx"
).read_text("utf-8") ).read_text("utf-8")
assert "Подключить управление K1" in connection_source assert "Запустить сканирование и локальный приём" in connection_source
assert "Открыть рабочее пространство K1" in connection_source assert "Имя войдёт в единственный канонический START" in connection_source
assert "Сохранить проект и подготовить локальный приём" in connection_source assert "этапы идут строго по записанному порядку и только после ответов K1" in (
assert "Запустить сканирование K1" in connection_source connection_source
assert "никаких временных автопереходов и повторов нет" in connection_source )
assert "Подключить управление K1" not in connection_source
assert "Открыть рабочее пространство K1" not in connection_source
assert "Сохранить проект и подготовить локальный приём" not in connection_source
assert "Остановить локальный приём" in connection_source assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source assert "Физическое состояние сканера остаётся неизвестным" in connection_source

View File

@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
CameraPreviewSelectRequest, CameraPreviewSelectRequest,
CompatibilityAttestationRequest, CompatibilityAttestationRequest,
ConnectRequest, ConnectRequest,
OpenApplicationControlSessionRequest,
OperatorPresenceRequest, OperatorPresenceRequest,
PrepareAcquisitionRequest, PrepareAcquisitionRequest,
ShadowApplicationControlArmRequest, ShadowApplicationControlArmRequest,
@ -169,6 +170,19 @@ PHYSICAL_ACCEPTANCE = OperatorPresenceRequest(
) )
def test_control_session_transport_metadata_does_not_leak_into_confirmation() -> None:
request = OpenApplicationControlSessionRequest(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
timezone_name="Europe/Moscow",
)
assert request.confirmation() == PHYSICAL_ACCEPTANCE.confirmation()
def service_with_fake_runtime( def service_with_fake_runtime(
tmp_path: Path, tmp_path: Path,
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]: ) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]: