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
|
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 13–14 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 **Запустить сканирование и
|
||||||
1–6 and then waits indefinitely for the next UI action.
|
локальный приём** once.
|
||||||
6. Choose **Открыть рабочее пространство K1** for operation 7.
|
6. Mission Core emits operations 1–6, waits for their correlated device
|
||||||
7. Enter the required project name and choose **Сохранить проект и подготовить
|
responses, then performs operation 7 and operations 8–10 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 8–10; 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 1–6 at connection, 7 at workspace entry, 8–10 at project
|
owner: one UI launch intent advances operations 1–6, 7, 8–10 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
|
||||||
|
|
|
||||||
|
|
@ -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 (1–6), workspace entry (7), project preparation (8–10), START, STOP
|
into response-gated REST actions for session open (1–6), workspace entry (7),
|
||||||
and steady-green confirmation. Preparing local reception happens before the
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 1–6. 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 1–6, operation 7 and operations 8–10 only after the
|
||||||
releases operations 8–10; 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 13–14.
|
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 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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 || "Сначала подключите устройство к Wi‑Fi"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
|
|
||||||
onClick={() => void prepareProject()}
|
|
||||||
>
|
|
||||||
{pendingAction === "live"
|
|
||||||
? "Сохраняем проект и готовим приём…"
|
|
||||||
: "Сохранить проект и подготовить локальный приём"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{controlPhase === "project-ready" && preparedAcquisition ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Icon name="activity" />}
|
|
||||||
disabled={isBusy || !physicalAcceptanceConfirmed}
|
|
||||||
onClick={() => void startLive()}
|
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"
|
||||||
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
|
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||||
: controlPhase === "workspace-requested"
|
: controlPhase === "workspace-requested"
|
||||||
? "Выполняется только операция входа в рабочее пространство."
|
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
|
||||||
: controlPhase === "project-requested"
|
: controlPhase === "project-requested"
|
||||||
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
|
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на 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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue