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
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 1314 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

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 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

View File

@ -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
16 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 810; 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 16, waits for their correlated device
responses, then performs operation 7 and operations 810 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 16 at connection, 7 at workspace entry, 810 at project
preparation, then separately permitted START and STOP. Physical START/STOP and
owner: one UI launch intent advances operations 16, 7, 810 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

View File

@ -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 (16), workspace entry (7), project preparation (810), 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 (16), workspace entry (7),
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
unbound READY and explicit visual confirmation. Page close, model switching,
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.
- 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

View File

@ -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 || "Сначала подключите устройство к 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}
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"
? "Выполняются только операции 16 записанного диалога. Следующий этап начнётся только по вашей кнопке."
? "Выполняются операции 16 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
: controlPhase === "workspace-requested"
? "Выполняется только операция входа в рабочее пространство."
? "После подтверждённых операций 16 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
? "Выполняются операции 810 и готовится локальный приём; имя ещё не отправляется на K1."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
</p>
</div>
) : (

View File

@ -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,

View File

@ -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):

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
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

View File

@ -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]: