fix(k1): simplify response-gated launch UX
This commit is contained in:
parent
7b7b5d6cad
commit
d7a2c22faf
16
README.md
16
README.md
|
|
@ -156,9 +156,10 @@ physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
|
|||
keepalive 60, exact
|
||||
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||
It consumes operation keys before publish and poisons unknown outcomes without
|
||||
retry. One background owner services that socket between separate connection,
|
||||
workspace, project, START, STOP and steady-green UI actions; navigation cannot
|
||||
emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||
retry. One background owner services that socket through response-gated
|
||||
connection, workspace, project, START, STOP and steady-green stages. The normal
|
||||
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
|
||||
response batch; START was not emitted. A second one-shot attempt correlated all
|
||||
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
|
||||
|
|
@ -172,11 +173,10 @@ immediate status read 12 and state-gated reads 13–14 onto one continuously own
|
|||
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
|
||||
for live `READY` plus physical standby confirmation. Standalone START and STOP
|
||||
are both rejected. The same socket is serviced during every operator/UI wait,
|
||||
and the short START/STOP permits are created only at their respective button
|
||||
confirmations rather than at connection time. This executor remains uninstalled and is not wired to the
|
||||
facade/UI; physical command acceptance is still paused. The temporary laboratory
|
||||
Keychain item was deleted.
|
||||
are both rejected. The same socket is serviced throughout launch and STOP
|
||||
confirmation, and no captured human delay is replayed as a protocol timer.
|
||||
Plugin v0.5.0 wires this executor to the facade and plugin UI; physical command
|
||||
acceptance remains an operator-run gate.
|
||||
|
||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||
standalone release install. The frontend consumes sibling `file:` packages from
|
||||
|
|
|
|||
|
|
@ -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 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 |
|
||||
| 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 |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
|
|
|
|||
|
|
@ -115,23 +115,24 @@ directly and use their sibling metadata receive timestamps when present.
|
|||
complete visible-device list.
|
||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
provisioning write. The backend does not retry the write automatically.
|
||||
5. Confirm operator presence, closed LixelGO, storage/power and steady green;
|
||||
choose **Подключить управление K1**. This emits only connection operations
|
||||
1–6 and then waits indefinitely for the next UI action.
|
||||
6. Choose **Открыть рабочее пространство K1** for operation 7.
|
||||
7. Enter the required project name and choose **Сохранить проект и подготовить
|
||||
локальный приём**. Mission Core normalizes it, prepares evidence reception
|
||||
and releases only operations 8–10; the name itself is not sent yet.
|
||||
8. After the project step is ready, choose **Запустить сканирование K1** once.
|
||||
The project name is carried by canonical START. Mission Core opens
|
||||
5. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
storage/power and steady green, then choose **Запустить сканирование и
|
||||
локальный приём** once.
|
||||
6. Mission Core emits operations 1–6, waits for their correlated device
|
||||
responses, then performs operation 7 and operations 8–10 behind the same
|
||||
operator intent. No captured human pause is treated as a timer and no device
|
||||
command is automatically retried.
|
||||
7. Mission Core prepares evidence reception before emitting the single
|
||||
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
|
||||
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.
|
||||
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
|
||||
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,
|
||||
waits for live unbound READY, and asks the operator to confirm the constant
|
||||
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
|
||||
the current writer. The data connector remains subscribe-only. Application
|
||||
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
|
||||
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.
|
||||
The fixed Keychain loader and dormant shadow coordinator remain available for
|
||||
inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT
|
||||
owner: operations 1–6 at connection, 7 at workspace entry, 8–10 at project
|
||||
preparation, then separately permitted START and STOP. Physical START/STOP and
|
||||
owner: one UI launch intent advances operations 1–6, 7, 8–10 and START through
|
||||
their live response barriers; STOP remains a separate explicit action. Physical START/STOP and
|
||||
durable native-project completion still require operator acceptance.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
|
|
|||
|
|
@ -138,9 +138,12 @@ connection time.
|
|||
|
||||
Plugin v0.5.0 installs this boundary through
|
||||
`application_session.py`. One background owner is the only thread allowed to
|
||||
touch the control MQTT client. REST/UI actions release exactly one checkpoint:
|
||||
session open (1–6), workspace entry (7), project preparation (8–10), START, STOP
|
||||
and steady-green confirmation. Preparing local reception happens before the
|
||||
touch the control MQTT client. The standard UI turns one explicit launch intent
|
||||
into response-gated REST actions for session open (1–6), workspace entry (7),
|
||||
project preparation (8–10) 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
|
||||
unbound READY and explicit visual confirmation. Page close, model switching,
|
||||
polling and service state reads cannot emit START/STOP; model switching is
|
||||
|
|
|
|||
|
|
@ -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 1–6. Workspace
|
||||
entry releases operation 7; saving the project and preparing local reception
|
||||
releases operations 8–10; a separate START click carries the project name and
|
||||
then waits for bound `SCANNING + project + init_ready` before operations 13–14.
|
||||
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 1–6, operation 7 and operations 8–10 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 13–14. 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,8 +181,23 @@ export function K1AcquisitionPipeline({
|
|||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<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
|
||||
checked={physicalAcceptanceConfirmed}
|
||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||
|
|
@ -189,86 +206,39 @@ export function K1AcquisitionPipeline({
|
|||
<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}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked
|
||||
}
|
||||
onClick={() => void startLive()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? "Отправляем один канонический START…"
|
||||
: "Запустить сканирование K1"}
|
||||
? controlPhase === "connecting"
|
||||
? "Синхронизация с K1…"
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Входим в рабочее пространство…"
|
||||
: controlPhase === "project-requested"
|
||||
? "Готовим проект и локальный приём…"
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования…"
|
||||
: "Запускаем K1 и локальный приём…"
|
||||
: preparedCanonicalLaunch
|
||||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
|
||||
{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,
|
||||
|
|
|
|||
|
|
@ -220,7 +220,16 @@ class OperatorPresenceRequest(StrictRequest):
|
|||
expected_physical_state_confirmed: Literal[True]
|
||||
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
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]
|
||||
connection_source = (
|
||||
repository_root
|
||||
|
|
@ -125,11 +125,14 @@ def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> Non
|
|||
/ "K1AcquisitionPipeline.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
assert "Подключить управление K1" in connection_source
|
||||
assert "Открыть рабочее пространство K1" in connection_source
|
||||
assert "Сохранить проект и подготовить локальный приём" in connection_source
|
||||
assert "Запустить сканирование K1" in connection_source
|
||||
assert "никаких временных автопереходов и повторов нет" in connection_source
|
||||
assert "Запустить сканирование и локальный приём" in connection_source
|
||||
assert "Имя войдёт в единственный канонический START" in connection_source
|
||||
assert "этапы идут строго по записанному порядку и только после ответов K1" 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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
|||
CameraPreviewSelectRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
OpenApplicationControlSessionRequest,
|
||||
OperatorPresenceRequest,
|
||||
PrepareAcquisitionRequest,
|
||||
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(
|
||||
tmp_path: Path,
|
||||
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
|
||||
|
|
|
|||
Loading…
Reference in New Issue