feat(k1): wire canonical control session to UI
This commit is contained in:
parent
36f0c93d2a
commit
7b7b5d6cad
22
README.md
22
README.md
|
|
@ -133,9 +133,9 @@ BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
|
|||
reviewed-TSV replay, raw-first evidence storage, device-reported scan
|
||||
time/distance/speed and measured preview metrics. Its frontend contribution now
|
||||
also owns an optional spatial-scene control block, including acquisition phase,
|
||||
telemetry and the stop action. On the active laboratory profile that stop action
|
||||
finalizes local reception only; physical K1 scanning is still started and
|
||||
stopped by the verified double-click.
|
||||
telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are implemented as inert protocol components.
|
||||
|
|
@ -147,16 +147,18 @@ as one private application-level value in the observed client, not a manual
|
|||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
|
||||
application authority without env/file/browser fallback, and a five-batch
|
||||
orchestrator passes all retained response barriers. The installed publisher
|
||||
boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0
|
||||
wires a dormant coordinator into the facade: explicit shadow arm holds authority
|
||||
orchestrator passes all retained response barriers. The legacy shadow publisher
|
||||
boundary remains structurally write-disabled and cannot call its sink. Its
|
||||
dormant coordinator can hold authority
|
||||
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
|
||||
it together with the orchestrator. A separate, uninstalled physical-acceptance
|
||||
transport now reproduces MQTT 3.1.1 `clean_session=false`, keepalive 60, exact
|
||||
it together with the orchestrator. Plugin v0.5.0 installs a separate
|
||||
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. Neither component is wired to facade/UI; `vendor_writes_enabled` remains
|
||||
false. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -322,12 +322,13 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
|||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
|
||||
случайные GATT writes и автоматические повторы запрещены.
|
||||
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||
физического сканирования остаются за кнопкой K1.
|
||||
- Exact start/stop codec, response correlator и device-status state machine
|
||||
остаются inert: они не импортируют MQTT transport. Header derivation известен,
|
||||
но legitimate OpenAPI key provisioning и durable post-stop save gate не
|
||||
доказаны, поэтому `vendor_writes_enabled=false`.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
- Exact start/stop codec, response correlator и device-status state machine не
|
||||
владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
|
||||
контуром без wall-clock переходов, reconnect или automatic retry; после STOP
|
||||
обязательны live READY и подтверждённый постоянный зелёный индикатор.
|
||||
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
|
||||
формируют disposable preview. При перегрузке preview может быть отброшен,
|
||||
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
|
||||
|
|
|
|||
|
|
@ -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 | PAUSE (accepted START followed by `ALGORITHM_ERROR`) — first live attempt stopped before START on a batch-3 correlation failure; a second attempt correlated all 10 pre-START requests plus START but collapsed UI stages into 365 ms and omitted the retained post-START/control-session lifecycle. The collapsed path and standalone START/STOP are disabled. An uninstalled offline executor now uses explicit UI checkpoints plus live DeviceStatus gates; no further physical write is authorized yet |
|
||||
| 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 |
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ operator power cycle restored steady green.
|
|||
This disproves the earlier assumption that byte-matched pre-START payloads plus
|
||||
a correlated START acknowledgement are sufficient acceptance. The legacy
|
||||
collapsed `run_bootstrap()` executor now rejects before emission. Its
|
||||
replacement is an uninstalled acceptance-only session owner. It requires the
|
||||
replacement is an operator-driven acceptance session owner. It requires the
|
||||
observed connection, workspace-entry and project-prompt checkpoints instead of
|
||||
wall-clock floors, sends the immediate post-START ModelingStatus read, services
|
||||
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
|
||||
|
|
@ -270,9 +270,10 @@ standby. System-error and DeviceStatus reports are decoded into redacted safety
|
|||
state; a fault forbids further automatic action. Standalone START/STOP are
|
||||
disabled. MQTT keepalive/report processing also continues while the operator is
|
||||
between UI actions; the 15–120-second command permit is created at the actual
|
||||
START or STOP confirmation, not at connection time. This path is
|
||||
synthetic-test-only and remains outside facade/UI, so it
|
||||
does not authorize a new physical attempt.
|
||||
START or STOP confirmation, not at connection time. Plugin v0.5.0 now exposes
|
||||
this path through separate plugin-owned UI actions. Repository tests use only
|
||||
in-memory transports; physical execution occurs only from an explicit operator
|
||||
click against an already selected direct-LAN K1.
|
||||
|
||||
The one-time Keychain administration command uses `security -w` as the final
|
||||
argument so Apple's own TTY prompt receives the authority. Mission Core never
|
||||
|
|
|
|||
|
|
@ -115,44 +115,45 @@ 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. Enter the required project name. Mission Core NFKC-normalizes and trims it,
|
||||
rejects control/surrogate characters or more than 96 characters, and stores
|
||||
it as local display metadata rather than a path. The read-only profile does
|
||||
not send it to K1.
|
||||
6. When K1 reports a non-AP private address, choose **Подготовить локальный
|
||||
приём данных**. The stronger “initiate device work” wording appears only for
|
||||
a future profile that actually authorizes a vendor write.
|
||||
7. Wait until the UI reports that the local Rerun bridge is ready. No manual
|
||||
viewer URL is needed.
|
||||
8. Open **Наблюдение → Пространственная сцена**. The K1 plugin's optional scene
|
||||
block shows the local preparation/acquisition phase and stop action.
|
||||
9. Double-click the physical K1 button to start scanning. Real point frames and
|
||||
pose/trajectory updates then appear in the embedded viewport.
|
||||
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
|
||||
**Наблюдение → Пространственная сцена** 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
|
||||
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
|
||||
time, route distance and speed. These are device reports, not values inferred
|
||||
from a browser timer or accumulated across a device counter reset.
|
||||
11. Double-click K1 again to stop physical scanning and wait for steady green,
|
||||
then choose **Остановить локальный приём** in the scene or device workflow
|
||||
so capture, camera archive and summaries are finalized. That UI action does
|
||||
not stop K1 on the active profile.
|
||||
11. 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.
|
||||
|
||||
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
|
||||
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
|
||||
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 connector subscribes to the fixed report-topic
|
||||
allowlist and does not publish an application request or modeling command.
|
||||
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.
|
||||
|
||||
The recovered command substrate is intentionally inert. It byte-matches the ten
|
||||
The recovered command substrate byte-matches the ten
|
||||
observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
|
||||
responses, and classifies live device states without importing MQTT or
|
||||
publishing anything. One private application-level OpenAPI authority is kept
|
||||
responses, and classifies live device states before any command transition. One
|
||||
private application-level OpenAPI authority is kept
|
||||
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
||||
The fixed Keychain loader, bounded execution lease and dormant facade
|
||||
orchestrator now exist. No emission method or live MQTT sink is installed, and
|
||||
an acceptance-only transport exists outside the facade. Keychain provisioning,
|
||||
physical START/STOP and durable save completion remain open, so
|
||||
`vendor_writes_enabled` is false and the physical-button workflow is canonical.
|
||||
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
|
||||
durable native-project completion still require operator acceptance.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
||||
|
|
|
|||
|
|
@ -138,21 +138,20 @@ after explicit operator attestation of firmware `3.0.2` and direct-LAN topology,
|
|||
and records the basis as `operator-attested`. That claim is not device-derived
|
||||
proof. The profile independently records observation, decode, replay,
|
||||
physical-verification and write evidence. Loading it cannot authorize a
|
||||
transport mutation. Application-command publishing remains disabled; the
|
||||
separately reviewed BLE Wi-Fi provisioning write retains its own explicit
|
||||
operator gate.
|
||||
transport mutation. BLE Wi-Fi provisioning and application control retain
|
||||
separate explicit operator gates.
|
||||
|
||||
An exact but inert K1 application-control substrate now lives inside the vendor
|
||||
An exact K1 application-control substrate now lives inside the vendor
|
||||
plugin. It byte-matches the retained ten-request pre-START dialogue plus
|
||||
START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
|
||||
device-status values into an observation-only state machine. The OpenAPI value
|
||||
device-status values into a fail-closed state machine. The OpenAPI value
|
||||
is one private application-level authority; vendor ID and serial come from the
|
||||
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
|
||||
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator
|
||||
are present, but there is no emission method or live MQTT sink. Keychain item
|
||||
provisioning and stable-artifact proof after STOP remain unresolved. A separate
|
||||
acceptance-only transport is not imported by the facade, so
|
||||
`vendor_writes_enabled=false` remains authoritative.
|
||||
BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
|
||||
single interactive MQTT session from connection through STOP/standby, with one
|
||||
explicit UI action per recorded lifecycle boundary and no automatic retry. A
|
||||
fixed Keychain loader and dormant shadow coordinator remain present. Stable
|
||||
physical proof after STOP remains unresolved; write capability is exposed only
|
||||
while that interactive socket owner is active.
|
||||
|
||||
## Semantic lifecycle
|
||||
|
||||
|
|
|
|||
|
|
@ -66,13 +66,16 @@ performs no implicit stop.
|
|||
|
||||
K1 project names are required, NFKC-normalized and trimmed, rejected for control
|
||||
or surrogate characters or more than 96 Unicode characters, and sent to the
|
||||
backend as local display metadata. The active profile has
|
||||
`vendor_writes_enabled=false`, so its
|
||||
backend as local display metadata. At the time of this ADR the active profile
|
||||
had `vendor_writes_enabled=false`, so its
|
||||
buttons and scene control say that they prepare/stop local reception. Before an
|
||||
acquisition exists, device-start wording is gated by active-control capability;
|
||||
after start, calibration/device-stop wording additionally requires a
|
||||
plugin-commanded acquisition mode.
|
||||
|
||||
The application-control portion is superseded by ADR 0012 and plugin v0.5.0;
|
||||
the plugin ownership and host-boundary decisions remain unchanged.
|
||||
|
||||
Plugin CSS is imported with the contribution and scoped below
|
||||
`.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or
|
||||
unscoped global selectors.
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ shadow arm/disarm/state actions and redacted snapshots. The coordinator has no
|
|||
request-emission method and the installed sink is explicitly uninstalled; this
|
||||
runtime wiring does not widen write authority.
|
||||
|
||||
`application_mqtt.py` and `application_acceptance.py` provide the next, still
|
||||
uninstalled physical-acceptance boundary. The MQTT type repeats the retained
|
||||
`application_mqtt.py` and `application_acceptance.py` provide the reviewed
|
||||
physical-acceptance boundary. The MQTT type repeats the retained
|
||||
3.1.1 session (`clean_session=false`, keepalive 60), subscribes only to exact
|
||||
known response topics, admits only reviewed request topics and QoS2/retain-false
|
||||
envelopes, and consumes explicit bootstrap ordinals/modeling actions before the
|
||||
|
|
@ -134,8 +134,18 @@ MQTT through explicit STOP and waits for live READY plus operator-confirmed
|
|||
standby. Fault/status observations are redacted and any fault forbids automatic
|
||||
continuation. The socket remains serviced during operator/UI waits, and each
|
||||
short action permit is created at its button confirmation rather than at
|
||||
connection time. These types are synthetic-test-only, not imported by the facade,
|
||||
declared as plugin actions or represented by UI controls.
|
||||
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
|
||||
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
|
||||
blocked while the socket is open. Repository tests replace the transport with
|
||||
in-memory fakes and perform no equipment I/O.
|
||||
|
||||
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
|
||||
`security add-generic-password ... -w` prompt with `-w` last. The private value
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ The plugin owns:
|
|||
- BLE discovery hints and K1 GATT metadata;
|
||||
- the reviewed firmware-3 Wi-Fi provisioning profile;
|
||||
- K1 LAN status and private-address validation;
|
||||
- subscribe-only MQTT transport and report-topic allowlist;
|
||||
- subscribe-only data MQTT transport plus a separately bounded canonical
|
||||
application-control transport;
|
||||
- native `.k1mqtt` capture;
|
||||
- firmware-scoped protobuf/LZ4 and legacy codecs;
|
||||
- normalization of K1 point cloud and pose, plus raw-only preservation of the
|
||||
|
|
@ -31,8 +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 dormant application-control facade boundary: explicit shadow arm/disarm,
|
||||
a 15–300 second Keychain-backed in-memory authority lease, and redacted state.
|
||||
- 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 plugin does not own:
|
||||
|
||||
|
|
@ -61,10 +63,11 @@ legacy router; both paths now cross the same runtime transport and delegate to t
|
|||
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
||||
run outside the FastAPI event loop.
|
||||
|
||||
Model switching calls the plugin deactivation hook. An active acquisition uses
|
||||
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
|
||||
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
||||
physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
|
||||
Model switching calls the plugin deactivation hook. It is rejected while the
|
||||
canonical K1 control socket is open, so navigation can never become an implicit
|
||||
device STOP. Operator-manual acquisition uses semantic `acquisition.stop` in
|
||||
`capture-only` mode; replay and pre-v1alpha2 sessions retain the legacy
|
||||
`stream.stop` shim. BLE, MQTT, codec,
|
||||
archive discovery and RRD export modules now live under the plugin package
|
||||
after replay parity and physical K1 regression. The current transport remains
|
||||
in-process and its health is lifecycle-only; process isolation, crash/restart
|
||||
|
|
@ -81,18 +84,18 @@ device I/O with:
|
|||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
```
|
||||
|
||||
Plugin v0.4.0 does not widen device authority. Its application-control
|
||||
coordinator has no request-emission method, no live MQTT sink and no UI control;
|
||||
`vendor_writes_enabled` remains false. A separate uninstalled acceptance-only
|
||||
transport now implements exact subscriptions, QoS2 completion, response
|
||||
barriers and no-retry poisoning. The admin CLI can provision the fixed Keychain
|
||||
item through Apple's hidden prompt without accepting the value as an argument.
|
||||
Neither path is imported by the facade. The first physical attempt emitted only
|
||||
bootstrap ordinals 1–6 and failed closed on live batch-3 response correlation;
|
||||
START was not emitted and the K1 remained READY. The temporary lab Keychain item
|
||||
was deleted. An OS-independent plugin/edge authority provider, redacted live
|
||||
response comparison and a newly permitted full START/STOP acceptance remain
|
||||
required.
|
||||
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
|
||||
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
|
||||
is emitted merely by loading the plugin, opening the page, navigating, polling
|
||||
state or running repository tests. Full v0.5.0 physical acceptance remains an
|
||||
operator-run gate.
|
||||
|
||||
The optional owner-controlled iPhone/LixelGO observation tool lives under
|
||||
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ The contribution contains:
|
|||
|
||||
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the
|
||||
reviewed Wi-Fi provisioning write;
|
||||
- `K1AcquisitionPipeline` for live receiver preparation, operator-manual K1
|
||||
start/stop and compatibility file replay;
|
||||
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
|
||||
START checkpoints, local receiver preparation and compatibility file replay;
|
||||
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
|
||||
READY plus steady-green completion gate;
|
||||
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
|
||||
observation-source mapping and scoped styles;
|
||||
- typed v0.4.0 shadow application-control state/arm/disarm contracts. No button
|
||||
is rendered because the plugin has no live command transport;
|
||||
- typed v0.5.0 interactive application-control state plus legacy shadow
|
||||
inspection contracts;
|
||||
- `plugin.ts`, which binds the manifest `device.connection` component key to
|
||||
the runtime provider and connection view.
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,47 @@ export interface XgridsApplicationControlExecution {
|
|||
can_emit_requests: false;
|
||||
}
|
||||
|
||||
export type XgridsApplicationControlPhase =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connection-ready"
|
||||
| "workspace-requested"
|
||||
| "workspace-ready"
|
||||
| "project-requested"
|
||||
| "project-ready"
|
||||
| "start-requested"
|
||||
| "initializing"
|
||||
| "scanning"
|
||||
| "stop-requested"
|
||||
| "stopping"
|
||||
| "awaiting-standby-confirmation"
|
||||
| "completed"
|
||||
| "closed"
|
||||
| "failed";
|
||||
|
||||
export interface XgridsApplicationControlSession {
|
||||
mode: "interactive-canonical";
|
||||
state: XgridsApplicationControlPhase;
|
||||
control_socket_open: boolean;
|
||||
can_open: boolean;
|
||||
can_enter_workspace: boolean;
|
||||
can_prepare_project: boolean;
|
||||
can_start: boolean;
|
||||
can_stop: boolean;
|
||||
can_confirm_standby: boolean;
|
||||
pending_operator_action?: string | null;
|
||||
scripted_transitions: false;
|
||||
automatic_retry: false;
|
||||
outcome_unknown: boolean;
|
||||
failure?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
safe_to_retry?: boolean;
|
||||
} | null;
|
||||
dialogue?: Record<string, unknown> | null;
|
||||
transport?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface XgridsAcquisition {
|
||||
schema_version?: string;
|
||||
acquisition_id: string;
|
||||
|
|
@ -228,6 +269,7 @@ export interface XgridsK1State {
|
|||
compatibility?: XgridsCompatibilityState | null;
|
||||
modeling_control_safety?: XgridsModelingControlSafety | null;
|
||||
application_control_execution?: XgridsApplicationControlExecution | null;
|
||||
application_control_session?: XgridsApplicationControlSession | null;
|
||||
device_ref?: XgridsDeviceRef | null;
|
||||
device_session?: XgridsDeviceSession | null;
|
||||
acquisition?: XgridsAcquisition | null;
|
||||
|
|
@ -288,6 +330,7 @@ export interface StartAcquisitionRequest {
|
|||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
physical_acceptance?: OperatorPresenceConfirmation;
|
||||
}
|
||||
|
||||
export interface StopAcquisitionRequest {
|
||||
|
|
@ -297,6 +340,7 @@ export interface StopAcquisitionRequest {
|
|||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
physical_acceptance?: OperatorPresenceConfirmation;
|
||||
}
|
||||
|
||||
export interface AbortAcquisitionRequest {
|
||||
|
|
@ -335,6 +379,23 @@ export interface ShadowApplicationControlArmRequest {
|
|||
timezone_name: string;
|
||||
}
|
||||
|
||||
export interface OperatorPresenceConfirmation {
|
||||
operator_present: true;
|
||||
owner_controlled_device: true;
|
||||
lixelgo_closed: true;
|
||||
battery_storage_confirmed: true;
|
||||
expected_physical_state_confirmed: true;
|
||||
}
|
||||
|
||||
export interface OpenApplicationControlSessionRequest
|
||||
extends OperatorPresenceConfirmation {
|
||||
timezone_name: string;
|
||||
}
|
||||
|
||||
export interface EnterApplicationWorkspaceRequest {
|
||||
operator_confirmed: true;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
|
|
@ -496,6 +557,22 @@ export const xgridsK1Api = {
|
|||
disarmShadowApplicationControl(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.applicationControlShadowDisarm);
|
||||
},
|
||||
|
||||
openApplicationControlSession(
|
||||
body: OpenApplicationControlSessionRequest,
|
||||
): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.applicationControlSessionOpen, body);
|
||||
},
|
||||
|
||||
enterApplicationWorkspace(
|
||||
body: EnterApplicationWorkspaceRequest,
|
||||
): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body);
|
||||
},
|
||||
|
||||
closeApplicationControlSession(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.applicationControlSessionClose);
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
|||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
isSoftwareCommandedAcquisition,
|
||||
isSourceRuntimeBusy,
|
||||
isVendorWriteCapable,
|
||||
recoverableAcquisition,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
} from "../lifecycle";
|
||||
import { normalizeProjectName, validateProjectName } from "../projectName";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import type { OperatorPresenceConfirmation } from "../api";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
|
|
@ -29,6 +31,14 @@ const sessionItems = [
|
|||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
profileConfirmed,
|
||||
|
|
@ -43,9 +53,14 @@ export function K1AcquisitionPipeline({
|
|||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
prepareAndStartAcquisition,
|
||||
openApplicationControlSession,
|
||||
enterApplicationWorkspace,
|
||||
closeApplicationControlSession,
|
||||
prepareAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
|
|
@ -55,12 +70,15 @@ export function K1AcquisitionPipeline({
|
|||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const projectNameValidation = validateProjectName(projectName);
|
||||
const vendorWriteCapable = isVendorWriteCapable(state);
|
||||
const control = state?.application_control_session;
|
||||
const controlPhase = control?.state ?? "idle";
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
|
|
@ -103,16 +121,29 @@ export function K1AcquisitionPipeline({
|
|||
[sessionLocked],
|
||||
);
|
||||
|
||||
const submitLive = async () => {
|
||||
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 startLive = async () => {
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => prepareAndStartAcquisition({
|
||||
project_name: projectNameValidation.value,
|
||||
...(targetHost ? { host: targetHost } : {}),
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
}),
|
||||
() => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
|
|
@ -148,52 +179,114 @@ export function K1AcquisitionPipeline({
|
|||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Обязательное поле · до 96 символов"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
setProjectNameTouched(true);
|
||||
}}
|
||||
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
||||
disabled={sessionLocked}
|
||||
autoComplete="off"
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
|
||||
placeholder="Например, Испытание маршрута 01"
|
||||
/>
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
disabled={sessionLocked}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
|
||||
: vendorWriteCapable
|
||||
? "Инициировать приём данных и работу устройства"
|
||||
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
|
||||
</Button>
|
||||
{(control?.can_open ?? controlPhase === "idle") ? (
|
||||
<>
|
||||
<Checker
|
||||
checked={physicalAcceptanceConfirmed}
|
||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||
onChange={setPhysicalAcceptanceConfirmed}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed}
|
||||
onClick={() => void openControl()}
|
||||
>
|
||||
{pendingAction === "control"
|
||||
? "Открываем канонический диалог…"
|
||||
: "Подключить управление K1"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{controlPhase === "connection-ready" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void enterApplicationWorkspace()}
|
||||
>
|
||||
{pendingAction === "control"
|
||||
? "Открываем рабочее пространство…"
|
||||
: "Открыть рабочее пространство K1"}
|
||||
</Button>
|
||||
) : null}
|
||||
{["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? (
|
||||
<>
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Будет отправлено только в отдельном START"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
setProjectNameTouched(true);
|
||||
}}
|
||||
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
||||
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
|
||||
autoComplete="off"
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Сохранение имени само по себе не отправляет START на устройство."}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Тот же direct-LAN адрес, который вернул выбранный K1"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
|
||||
onClick={() => void prepareProject()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? "Сохраняем проект и готовим приём…"
|
||||
: "Сохранить проект и подготовить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{controlPhase === "project-ready" && preparedAcquisition ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !physicalAcceptanceConfirmed}
|
||||
onClick={() => void startLive()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? "Отправляем один канонический START…"
|
||||
: "Запустить сканирование K1"}
|
||||
</Button>
|
||||
) : null}
|
||||
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
onClick={() => void closeApplicationControlSession()}
|
||||
>
|
||||
Закрыть управляющую сессию без START
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
|
||||
: liveTargetReady
|
||||
? vendorWriteCapable
|
||||
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
|
||||
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
|
||||
: controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Выполняется только операция входа в рабочее пространство."
|
||||
: controlPhase === "project-requested"
|
||||
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -219,7 +312,22 @@ export function K1AcquisitionPipeline({
|
|||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>
|
||||
{control?.can_confirm_standby ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
onClick={() => void stop(
|
||||
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
|
||||
)}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
|
||||
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type { AcquisitionState, XgridsAcquisition } from "../api";
|
||||
import type {
|
||||
AcquisitionState,
|
||||
OperatorPresenceConfirmation,
|
||||
XgridsAcquisition,
|
||||
} from "../api";
|
||||
import {
|
||||
isSoftwareCommandedAcquisition,
|
||||
shouldRenderSpatialControls,
|
||||
|
|
@ -19,6 +23,14 @@ interface PhasePresentation {
|
|||
busy: boolean;
|
||||
}
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
|
||||
function phasePresentation(
|
||||
acquisition: XgridsAcquisition,
|
||||
softwareCommanded: boolean,
|
||||
|
|
@ -58,8 +70,10 @@ function phasePresentation(
|
|||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: "Ожидание остановки на устройстве",
|
||||
detail: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
|
|
@ -94,7 +108,7 @@ function formatDuration(seconds: number): string {
|
|||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop } = controller;
|
||||
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
|
|
@ -108,8 +122,14 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
|||
acquisition.state,
|
||||
);
|
||||
const stopDisabled = pendingAction !== null || stopping;
|
||||
const controlFailure =
|
||||
state.application_control_session?.state === "failed"
|
||||
? state.application_control_session.failure?.message ||
|
||||
"Канонический диалог остановлен; автоматический повтор запрещён."
|
||||
: null;
|
||||
const actionFailure = spatialActionFailure(
|
||||
controller.error ??
|
||||
controlFailure ??
|
||||
(cleanupPending
|
||||
? "Локальный поток или архив ещё не завершён. Повторите остановку."
|
||||
: null),
|
||||
|
|
@ -149,7 +169,7 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
|||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={stopDisabled}
|
||||
onClick={() => void stop()}
|
||||
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
|
||||
|
|
@ -159,6 +179,16 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
|||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
{state?.application_control_session?.can_confirm_standby ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,4 +42,16 @@ export const xgridsK1Actions = Object.freeze({
|
|||
xgridsK1Manifest,
|
||||
"application-control.shadow-disarm",
|
||||
),
|
||||
applicationControlSessionOpen: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"application-control.session.open",
|
||||
),
|
||||
applicationControlWorkspaceEnter: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"application-control.workspace.enter",
|
||||
),
|
||||
applicationControlSessionClose: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"application-control.session.close",
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,13 +140,17 @@ export function XgridsK1RuntimeProvider({
|
|||
if (!activeModel) return;
|
||||
return registerDeactivation(async () => {
|
||||
if (controller.pendingAction !== null) return false;
|
||||
// Always ask the backend to stop. The browser snapshot may be stale or not
|
||||
// loaded yet, while a previous local capture is still alive.
|
||||
// Never translate navigation/model switching into a K1 control command.
|
||||
// The operator must explicitly finish or close the canonical dialogue.
|
||||
if (controller.state?.application_control_session?.control_socket_open) {
|
||||
return false;
|
||||
}
|
||||
return controller.stop();
|
||||
});
|
||||
}, [
|
||||
activeModel,
|
||||
controller.pendingAction,
|
||||
controller.state?.application_control_session?.control_socket_open,
|
||||
controller.stop,
|
||||
registerDeactivation,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type EventSocketStatus,
|
||||
type OpenApplicationControlSessionRequest,
|
||||
type OperatorPresenceConfirmation,
|
||||
type PrepareAcquisitionRequest,
|
||||
type ReplayRequest,
|
||||
type XgridsK1State,
|
||||
|
|
@ -25,6 +27,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
|
|||
export type PendingAction =
|
||||
| "scan"
|
||||
| "connect"
|
||||
| "control"
|
||||
| "live"
|
||||
| "replay"
|
||||
| "stop"
|
||||
|
|
@ -167,7 +170,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
[run, state],
|
||||
);
|
||||
|
||||
const prepareAndStartAcquisition = useCallback(
|
||||
const openApplicationControlSession = useCallback(
|
||||
(request: OpenApplicationControlSessionRequest) =>
|
||||
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const enterApplicationWorkspace = useCallback(
|
||||
() =>
|
||||
run("control", () =>
|
||||
xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }),
|
||||
),
|
||||
[run],
|
||||
);
|
||||
|
||||
const closeApplicationControlSession = useCallback(
|
||||
() => run("control", () => xgridsK1Api.closeApplicationControlSession()),
|
||||
[run],
|
||||
);
|
||||
|
||||
const prepareAcquisition = useCallback(
|
||||
(request: PrepareAcquisitionRequest) =>
|
||||
run("live", async () => {
|
||||
const plan = liveStartPlan(state);
|
||||
|
|
@ -178,17 +200,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
}
|
||||
if (plan === "already-running" && state) return state;
|
||||
|
||||
const prepared =
|
||||
return (
|
||||
plan === "resume-prepared" && state
|
||||
? state
|
||||
: await xgridsK1Api.prepareAcquisition(request);
|
||||
const acquisition = prepared.acquisition;
|
||||
if (!acquisition?.acquisition_id) {
|
||||
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
|
||||
: await xgridsK1Api.prepareAcquisition(request)
|
||||
);
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const startPreparedAcquisition = useCallback(
|
||||
(physicalAcceptance: OperatorPresenceConfirmation) =>
|
||||
run("live", async () => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
|
||||
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
|
||||
}
|
||||
return xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: physicalAcceptance,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
|
|
@ -200,22 +231,57 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() =>
|
||||
(physicalAcceptance?: OperatorPresenceConfirmation) =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
|
||||
if (acquisition && !acquisitionTerminal) {
|
||||
const softwareCommanded = isSoftwareCommandedAcquisition(state);
|
||||
if (softwareCommanded && !physicalAcceptance) {
|
||||
throw new ApiError(
|
||||
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
|
||||
);
|
||||
}
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
|
||||
mode: softwareCommanded ? "graceful" : "capture-only",
|
||||
...(physicalAcceptance
|
||||
? { physical_acceptance: physicalAcceptance }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
|
||||
return xgridsK1Api.stopSessionCompatibility();
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const confirmStoppedAtSteadyGreen = useCallback(
|
||||
() =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
|
||||
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
|
||||
}
|
||||
const stopOperation = [...(state?.operations ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(operation) =>
|
||||
operation.action === "acquisition.stop" &&
|
||||
operation.status === "operator_action_required",
|
||||
);
|
||||
if (!stopOperation) {
|
||||
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
|
||||
}
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "graceful",
|
||||
operator_confirmed: true,
|
||||
operation_id: stopOperation.operation_id,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
|
|
@ -348,9 +414,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
prepareAndStartAcquisition,
|
||||
openApplicationControlSession,
|
||||
enterApplicationWorkspace,
|
||||
closeApplicationControlSession,
|
||||
prepareAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
setObservationSourceActive,
|
||||
updateViewerSettings,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"kind": "DevicePlugin",
|
||||
"metadata": {
|
||||
"id": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"displayName": "XGRIDS K1 Integration"
|
||||
},
|
||||
"spec": {
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"device.discovery.ble",
|
||||
"device.provisioning.wifi-over-ble",
|
||||
"network.mqtt.subscribe-private-lan",
|
||||
"network.mqtt.publish-private-lan",
|
||||
"network.rtsp.read-private-lan",
|
||||
"media.publish-local-browser",
|
||||
"evidence.write-session-artifacts",
|
||||
|
|
@ -49,7 +50,10 @@
|
|||
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.shadow-state", "mutating": false, "secretFields": [] },
|
||||
{ "id": "application-control.shadow-arm", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] }
|
||||
{ "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.session.open", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.workspace.enter", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.session.close", "mutating": true, "secretFields": [] }
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,14 +39,13 @@ capture verifies the `ModelingRequest` topic, action values, field layout,
|
|||
literal `{device_id}:ModelingRequest` session relation, retained start settings
|
||||
and numeric success code. Retained client/wire evidence identifies OpenAPI as
|
||||
one private application-level value rather than a per-scanner profile. A fixed
|
||||
read-only macOS Keychain loader, bounded in-memory lease and dormant facade
|
||||
orchestrator now exist. The installed runtime has no request-emission method or
|
||||
live MQTT sink. A separate uninstalled acceptance transport implements the
|
||||
retained MQTT session, exact response subscriptions and poison-on-unknown
|
||||
one-shot behavior. Operator-owned Keychain item provisioning, physical
|
||||
acceptance, durable save completion and rollback evidence remain unresolved.
|
||||
Acquisition therefore stays `operator-manual` through the verified physical
|
||||
double-click.
|
||||
read-only macOS Keychain loader, bounded shadow lease and dormant facade
|
||||
orchestrator remain. Plugin v0.5.0 separately installs an interactive acceptance
|
||||
transport implementing the retained MQTT session, exact response subscriptions
|
||||
and poison-on-unknown one-shot behavior. The descriptive profile still cannot
|
||||
enable writes by itself: active control exists only while the operator-opened
|
||||
canonical session owns the socket. Physical acceptance, durable native save
|
||||
completion and rollback evidence remain unresolved.
|
||||
The standalone encoder models the recovered wire schema, including enum values
|
||||
outside the retained request. It is not an authorization policy: any future
|
||||
publisher must enforce the exact profile mapping (`2/1/0`, omitted
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
|||
UninstalledApplicationPublishSink,
|
||||
WriteDisabledOneShotPublisher,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
|
||||
InteractiveApplicationControlSession,
|
||||
OperatorPresenceConfirmation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
LiveModelingControlSafety,
|
||||
|
|
@ -85,7 +89,7 @@ from k1link.web.plugin_runtime import (
|
|||
)
|
||||
|
||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||
XGRIDS_K1_PLUGIN_VERSION = "0.4.0"
|
||||
XGRIDS_K1_PLUGIN_VERSION = "0.5.0"
|
||||
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
|
||||
|
|
@ -137,6 +141,9 @@ ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
|
|||
ACTION_APPLICATION_CONTROL_SHADOW_STATE = "application-control.shadow-state"
|
||||
ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm"
|
||||
ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm"
|
||||
ACTION_APPLICATION_CONTROL_SESSION_OPEN = "application-control.session.open"
|
||||
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER = "application-control.workspace.enter"
|
||||
ACTION_APPLICATION_CONTROL_SESSION_CLOSE = "application-control.session.close"
|
||||
|
||||
RequestedStreamId = Literal[
|
||||
"spatial.point-cloud.live",
|
||||
|
|
@ -205,6 +212,17 @@ class OperationContextRequest(StrictRequest):
|
|||
deadline_seconds: float | None = Field(default=None, ge=1.0, le=86_400.0)
|
||||
|
||||
|
||||
class OperatorPresenceRequest(StrictRequest):
|
||||
operator_present: Literal[True]
|
||||
owner_controlled_device: Literal[True]
|
||||
lixelgo_closed: Literal[True]
|
||||
battery_storage_confirmed: Literal[True]
|
||||
expected_physical_state_confirmed: Literal[True]
|
||||
|
||||
def confirmation(self) -> OperatorPresenceConfirmation:
|
||||
return OperatorPresenceConfirmation(**self.model_dump())
|
||||
|
||||
|
||||
class PrepareAcquisitionRequest(OperationContextRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
|
|
@ -222,12 +240,14 @@ class PrepareAcquisitionRequest(OperationContextRequest):
|
|||
class StartAcquisitionRequest(OperationContextRequest):
|
||||
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
expected_state_revision: int | None = Field(default=None, ge=1)
|
||||
physical_acceptance: OperatorPresenceRequest | None = None
|
||||
|
||||
|
||||
class StopAcquisitionRequest(OperationContextRequest):
|
||||
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
mode: Literal["graceful", "capture-only"] = "capture-only"
|
||||
operator_confirmed: bool = False
|
||||
physical_acceptance: OperatorPresenceRequest | None = None
|
||||
|
||||
|
||||
class AbortAcquisitionRequest(OperationContextRequest):
|
||||
|
|
@ -271,6 +291,18 @@ class ShadowApplicationControlArmRequest(StrictRequest):
|
|||
)
|
||||
|
||||
|
||||
class OpenApplicationControlSessionRequest(OperatorPresenceRequest):
|
||||
timezone_name: str = Field(
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*$",
|
||||
)
|
||||
|
||||
|
||||
class EnterApplicationWorkspaceRequest(StrictRequest):
|
||||
operator_confirmed: Literal[True]
|
||||
|
||||
|
||||
class XgridsK1CompatibilityService:
|
||||
"""The proven K1 runtime kept intact behind the plugin facade."""
|
||||
|
||||
|
|
@ -313,10 +345,16 @@ class XgridsK1CompatibilityService:
|
|||
# The host-owned visual runtime receives the vendor normalizer
|
||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||
self._modeling_control_safety = LiveModelingControlSafety()
|
||||
authority_loader = (
|
||||
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
|
||||
)
|
||||
self._application_control = DormantApplicationControlCoordinator(
|
||||
application_authority_loader or MacOSKeychainApplicationAuthorityLoader(),
|
||||
authority_loader,
|
||||
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
|
||||
)
|
||||
self._application_control_session = InteractiveApplicationControlSession(
|
||||
authority_loader
|
||||
)
|
||||
self.runtime = VisualizationRuntime(
|
||||
normalizer=normalize_k1_message,
|
||||
message_observer=self._observe_runtime_message,
|
||||
|
|
@ -329,6 +367,7 @@ class XgridsK1CompatibilityService:
|
|||
@_serialized_acquisition_access
|
||||
def state(self) -> dict[str, Any]:
|
||||
application_control = self._application_control.snapshot().as_dict()
|
||||
application_control_session = self._application_control_session.snapshot()
|
||||
runtime = self.runtime.snapshot()
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
self._reconcile_acquisition(runtime, camera_preview)
|
||||
|
|
@ -389,6 +428,12 @@ class XgridsK1CompatibilityService:
|
|||
active_profile_id = (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None
|
||||
)
|
||||
active_control = application_control_session["state"] not in {
|
||||
"idle",
|
||||
"completed",
|
||||
"closed",
|
||||
"failed",
|
||||
}
|
||||
return {
|
||||
"contract_version": "missioncore.device-plugin-state/v1alpha2",
|
||||
"phase": phase,
|
||||
|
|
@ -400,7 +445,11 @@ class XgridsK1CompatibilityService:
|
|||
"profile_id": active_profile_id,
|
||||
"decision": "limited" if active_profile_id is not None else "unknown",
|
||||
"permitted_mode": (
|
||||
"read-only" if active_profile_id is not None else "evidence-only"
|
||||
"active-control"
|
||||
if active_profile_id is not None and active_control
|
||||
else "read-only"
|
||||
if active_profile_id is not None
|
||||
else "evidence-only"
|
||||
),
|
||||
"firmware_claim": (
|
||||
"operator-attested-exact-3.0.2"
|
||||
|
|
@ -408,7 +457,7 @@ class XgridsK1CompatibilityService:
|
|||
else "exact-3.0.2-profile-not-attested"
|
||||
),
|
||||
"attestation": compatibility_attestation,
|
||||
"vendor_writes_enabled": False,
|
||||
"vendor_writes_enabled": active_control,
|
||||
"camera_preview": (
|
||||
(
|
||||
"local-browser-adapter-available"
|
||||
|
|
@ -421,6 +470,7 @@ class XgridsK1CompatibilityService:
|
|||
},
|
||||
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
|
||||
"application_control_execution": application_control,
|
||||
"application_control_session": application_control_session,
|
||||
"device_ref": (
|
||||
{
|
||||
"device_id": device_id,
|
||||
|
|
@ -521,6 +571,11 @@ class XgridsK1CompatibilityService:
|
|||
return self.state()
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
control_state = self._application_control_session.snapshot()["state"]
|
||||
if control_state not in {"idle", "completed", "closed"}:
|
||||
raise RuntimeError(
|
||||
"сначала завершите или безопасно закройте текущую control-сессию K1"
|
||||
)
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
|
|
@ -714,6 +769,45 @@ class XgridsK1CompatibilityService:
|
|||
}
|
||||
return self.state()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def open_application_control_session(
|
||||
self,
|
||||
request: OpenApplicationControlSessionRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Open one continuous canonical dialogue; no later stage is implied."""
|
||||
|
||||
with self._lock:
|
||||
if self._provisioning_active:
|
||||
raise RuntimeError("нельзя открывать control-сессию во время настройки Wi-Fi")
|
||||
target = self._k1_ip
|
||||
attestation = self._compatibility_attestation
|
||||
acquisition = self._acquisition
|
||||
if target is None or attestation is None:
|
||||
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
|
||||
self._application_control.disarm()
|
||||
self._application_control_session.open(
|
||||
host=validate_private_ipv4(target),
|
||||
timezone_name=request.timezone_name,
|
||||
confirmation=request.confirmation(),
|
||||
)
|
||||
return self.state()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def enter_application_workspace(
|
||||
self,
|
||||
request: EnterApplicationWorkspaceRequest,
|
||||
) -> dict[str, Any]:
|
||||
del request
|
||||
self._application_control_session.enter_workspace()
|
||||
return self.state()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def close_application_control_session(self) -> dict[str, Any]:
|
||||
self._application_control_session.close_prestart()
|
||||
return self.state()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def arm_application_control_shadow(
|
||||
self,
|
||||
|
|
@ -751,6 +845,30 @@ class XgridsK1CompatibilityService:
|
|||
@_serialized_acquisition_access
|
||||
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
|
||||
self._application_control.disarm()
|
||||
control_state = str(self._application_control_session.snapshot()["state"])
|
||||
if control_state == "failed":
|
||||
raise RuntimeError(
|
||||
"control-сессия K1 завершилась ошибкой и требует ручной проверки"
|
||||
)
|
||||
if control_state in {
|
||||
"connecting",
|
||||
"connection-ready",
|
||||
"workspace-requested",
|
||||
"project-requested",
|
||||
"project-ready",
|
||||
"start-requested",
|
||||
"initializing",
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
} and control_state != "workspace-ready":
|
||||
raise RuntimeError(
|
||||
"каноническая control-сессия должна ожидать сохранения проекта"
|
||||
)
|
||||
control_mode: Literal["operator-manual", "plugin-commanded"] = (
|
||||
"plugin-commanded" if control_state == "workspace-ready" else "operator-manual"
|
||||
)
|
||||
requested_streams = _validated_requested_streams(request)
|
||||
target = request.host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
|
|
@ -762,6 +880,13 @@ class XgridsK1CompatibilityService:
|
|||
raise ValueError(
|
||||
"адрес точки доступа устройства нельзя использовать как direct-LAN target"
|
||||
)
|
||||
if control_mode == "plugin-commanded":
|
||||
with self._lock:
|
||||
control_target = self._k1_ip
|
||||
if control_target is None or target != validate_private_ipv4(control_target):
|
||||
raise ValueError(
|
||||
"локальный приём должен использовать тот же K1, что и control-сессия"
|
||||
)
|
||||
with self._lock:
|
||||
if self._acquisition_session_lease is not None:
|
||||
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
|
||||
|
|
@ -824,7 +949,7 @@ class XgridsK1CompatibilityService:
|
|||
device_id=device_id,
|
||||
device_session_id=device_session_id,
|
||||
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||
control_mode="operator-manual",
|
||||
control_mode=control_mode,
|
||||
requested_streams=requested_streams,
|
||||
target_host=target,
|
||||
duration_seconds=request.duration_seconds,
|
||||
|
|
@ -846,6 +971,8 @@ class XgridsK1CompatibilityService:
|
|||
self._compatibility_attestation = _attestation_snapshot(
|
||||
request.compatibility_attestation
|
||||
)
|
||||
if control_mode == "plugin-commanded":
|
||||
self._application_control_session.open_project_prompt()
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"succeeded",
|
||||
|
|
@ -867,6 +994,14 @@ class XgridsK1CompatibilityService:
|
|||
@_serialized_acquisition_access
|
||||
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
|
||||
acquisition = self._require_acquisition(request.acquisition_id)
|
||||
plugin_commanded = acquisition.control_mode == "plugin-commanded"
|
||||
if plugin_commanded:
|
||||
if request.physical_acceptance is None:
|
||||
raise ValueError(
|
||||
"START K1 требует явного подтверждения присутствия оператора"
|
||||
)
|
||||
if self._application_control_session.snapshot()["state"] != "project-ready":
|
||||
raise RuntimeError("канонический диалог K1 ещё не готов принять START")
|
||||
if (
|
||||
request.expected_state_revision is not None
|
||||
and request.expected_state_revision != acquisition.state_revision
|
||||
|
|
@ -877,6 +1012,11 @@ class XgridsK1CompatibilityService:
|
|||
{
|
||||
"acquisition_id": acquisition.acquisition_id,
|
||||
"expected_state_revision": request.expected_state_revision,
|
||||
"physical_acceptance": (
|
||||
request.physical_acceptance.model_dump(mode="json")
|
||||
if request.physical_acceptance is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
operation, created = self._operations.begin(
|
||||
|
|
@ -937,6 +1077,12 @@ class XgridsK1CompatibilityService:
|
|||
project_name=project_name,
|
||||
)
|
||||
self._arm_camera_recording(out_dir)
|
||||
if plugin_commanded:
|
||||
assert request.physical_acceptance is not None
|
||||
self._application_control_session.request_start(
|
||||
project_name=project_name,
|
||||
confirmation=request.physical_acceptance.confirmation(),
|
||||
)
|
||||
except Exception as exc:
|
||||
if owns_start:
|
||||
with self._lock:
|
||||
|
|
@ -975,6 +1121,7 @@ class XgridsK1CompatibilityService:
|
|||
@_serialized_acquisition_access
|
||||
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
|
||||
acquisition = self._require_acquisition(request.acquisition_id)
|
||||
plugin_commanded = acquisition.control_mode == "plugin-commanded"
|
||||
with self._lock:
|
||||
acquisition_state = acquisition.state
|
||||
expected_stop_operation_id = self._acquisition_stop_operation_id
|
||||
|
|
@ -989,6 +1136,12 @@ class XgridsK1CompatibilityService:
|
|||
raise ValueError(
|
||||
"подтверждение остановки должно ссылаться на исходную stop-operation"
|
||||
)
|
||||
if plugin_commanded and not self._application_control_session.snapshot()[
|
||||
"can_confirm_standby"
|
||||
]:
|
||||
raise RuntimeError(
|
||||
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
|
||||
)
|
||||
elif (
|
||||
request.mode == "graceful"
|
||||
and acquisition_state
|
||||
|
|
@ -996,11 +1149,27 @@ class XgridsK1CompatibilityService:
|
|||
"acquiring",
|
||||
"awaiting_external_stop",
|
||||
}
|
||||
and not (
|
||||
plugin_commanded
|
||||
and acquisition_state in {"starting", "awaiting_external_start"}
|
||||
and self._application_control_session.snapshot()["state"] == "scanning"
|
||||
)
|
||||
and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained)
|
||||
):
|
||||
raise ValueError(
|
||||
"graceful stop допустим только после подтверждённого потока point cloud"
|
||||
)
|
||||
if (
|
||||
plugin_commanded
|
||||
and request.mode == "graceful"
|
||||
and not request.operator_confirmed
|
||||
):
|
||||
if request.physical_acceptance is None:
|
||||
raise ValueError(
|
||||
"STOP K1 требует явного подтверждения присутствия оператора"
|
||||
)
|
||||
if self._application_control_session.snapshot()["state"] != "scanning":
|
||||
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
|
||||
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
ACTION_ACQUISITION_STOP,
|
||||
|
|
@ -1091,12 +1260,29 @@ class XgridsK1CompatibilityService:
|
|||
return self.state()
|
||||
|
||||
if request.mode == "graceful" and not request.operator_confirmed:
|
||||
if plugin_commanded:
|
||||
assert request.physical_acceptance is not None
|
||||
self._application_control_session.request_stop(
|
||||
confirmation=request.physical_acceptance.confirmation()
|
||||
)
|
||||
with self._lock:
|
||||
acquisition.transition(
|
||||
"awaiting_external_stop",
|
||||
message_code="acquisition.stop.operator_action_required",
|
||||
message_code=(
|
||||
"acquisition.stop.device_stopping"
|
||||
if plugin_commanded
|
||||
else "acquisition.stop.operator_action_required"
|
||||
),
|
||||
operator_instructions=(
|
||||
"Дважды нажмите физическую кнопку устройства и подтвердите остановку.",
|
||||
(
|
||||
"Канонический STOP запрошен один раз. Дождитесь READY, "
|
||||
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
|
||||
if plugin_commanded
|
||||
else (
|
||||
"Дважды нажмите физическую кнопку устройства и "
|
||||
"подтвердите остановку."
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
self._acquisition_stop_operation_id = operation.operation_id
|
||||
|
|
@ -1104,7 +1290,11 @@ class XgridsK1CompatibilityService:
|
|||
operation.operation_id,
|
||||
"operator_action_required",
|
||||
stage_code="awaiting-external-stop",
|
||||
message_code="acquisition.stop.operator_action_required",
|
||||
message_code=(
|
||||
"acquisition.stop.device_stopping"
|
||||
if plugin_commanded
|
||||
else "acquisition.stop.operator_action_required"
|
||||
),
|
||||
result={"acquisition_id": acquisition.acquisition_id},
|
||||
)
|
||||
return self.state()
|
||||
|
|
@ -1116,6 +1306,8 @@ class XgridsK1CompatibilityService:
|
|||
message_code="acquisition.stop.stopping_receiver",
|
||||
)
|
||||
try:
|
||||
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
|
||||
self._application_control_session.confirm_standby()
|
||||
with self._lock:
|
||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||
self._stop_acquisition_sources(
|
||||
|
|
@ -1190,6 +1382,11 @@ class XgridsK1CompatibilityService:
|
|||
if not created:
|
||||
return self.state()
|
||||
try:
|
||||
if (
|
||||
acquisition.control_mode == "plugin-commanded"
|
||||
and acquisition.state == "prepared"
|
||||
):
|
||||
self._application_control_session.close_prestart()
|
||||
with self._lock:
|
||||
should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES
|
||||
if should_abort:
|
||||
|
|
@ -1338,6 +1535,7 @@ class XgridsK1CompatibilityService:
|
|||
@_serialized_acquisition_access
|
||||
def close(self) -> None:
|
||||
self._application_control.close()
|
||||
self._application_control_session.close()
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
close_active_acquisition = (
|
||||
|
|
@ -1613,6 +1811,7 @@ class XgridsK1CompatibilityService:
|
|||
failed_stop_operation_id: str | None = None
|
||||
unconfirmed_stop_operation_id: str | None = None
|
||||
receiver_ready_operation_id: str | None = None
|
||||
receiver_plugin_commanded = False
|
||||
acquisition_id: str | None = None
|
||||
camera_terminal_status: Literal["complete", "failed"] | None = None
|
||||
camera_failure_code: str | None = None
|
||||
|
|
@ -1667,11 +1866,22 @@ class XgridsK1CompatibilityService:
|
|||
completed_operation_id = self._acquisition_start_operation_id
|
||||
acquisition_id = acquisition.acquisition_id
|
||||
elif acquisition.state == "starting" and source_ready:
|
||||
plugin_commanded = acquisition.control_mode == "plugin-commanded"
|
||||
receiver_plugin_commanded = plugin_commanded
|
||||
acquisition.transition(
|
||||
"awaiting_external_start",
|
||||
message_code="acquisition.start.operator_action_required",
|
||||
message_code=(
|
||||
"acquisition.start.device_initializing"
|
||||
if plugin_commanded
|
||||
else "acquisition.start.operator_action_required"
|
||||
),
|
||||
operator_instructions=(
|
||||
"Приёмник готов. Дважды нажмите физическую кнопку устройства.",
|
||||
(
|
||||
"Приёмник готов. Ожидаем канонический START и калибровку K1; "
|
||||
"не нажимайте физическую кнопку."
|
||||
if plugin_commanded
|
||||
else "Приёмник готов. Дважды нажмите физическую кнопку устройства."
|
||||
),
|
||||
),
|
||||
)
|
||||
receiver_ready_operation_id = self._acquisition_start_operation_id
|
||||
|
|
@ -1776,9 +1986,17 @@ class XgridsK1CompatibilityService:
|
|||
if receiver_ready_operation_id is not None:
|
||||
self._operations.transition_if_pending(
|
||||
receiver_ready_operation_id,
|
||||
"operator_action_required",
|
||||
stage_code="awaiting-external-start",
|
||||
message_code="acquisition.start.operator_action_required",
|
||||
"running" if receiver_plugin_commanded else "operator_action_required",
|
||||
stage_code=(
|
||||
"device-initializing"
|
||||
if receiver_plugin_commanded
|
||||
else "awaiting-external-start"
|
||||
),
|
||||
message_code=(
|
||||
"acquisition.start.device_initializing"
|
||||
if receiver_plugin_commanded
|
||||
else "acquisition.start.operator_action_required"
|
||||
),
|
||||
result={"acquisition_id": acquisition_id},
|
||||
)
|
||||
if completed_operation_id is not None:
|
||||
|
|
@ -1856,6 +2074,18 @@ class XgridsK1ServicePort(Protocol):
|
|||
|
||||
def verify_connection(self) -> dict[str, Any]: ...
|
||||
|
||||
def open_application_control_session(
|
||||
self,
|
||||
request: OpenApplicationControlSessionRequest,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def enter_application_workspace(
|
||||
self,
|
||||
request: EnterApplicationWorkspaceRequest,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def close_application_control_session(self) -> dict[str, Any]: ...
|
||||
|
||||
def arm_application_control_shadow(
|
||||
self,
|
||||
request: ShadowApplicationControlArmRequest,
|
||||
|
|
@ -1917,6 +2147,9 @@ class XgridsK1PluginFacade:
|
|||
ACTION_APPLICATION_CONTROL_SHADOW_STATE,
|
||||
ACTION_APPLICATION_CONTROL_SHADOW_ARM,
|
||||
ACTION_APPLICATION_CONTROL_SHADOW_DISARM,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
|
||||
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1975,6 +2208,23 @@ class XgridsK1PluginFacade:
|
|||
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.disarm_application_control_shadow)
|
||||
if action_id == ACTION_APPLICATION_CONTROL_SESSION_OPEN:
|
||||
open_request = OpenApplicationControlSessionRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.open_application_control_session,
|
||||
open_request,
|
||||
)
|
||||
if action_id == ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER:
|
||||
workspace_request = EnterApplicationWorkspaceRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.enter_application_workspace,
|
||||
workspace_request,
|
||||
)
|
||||
if action_id == ACTION_APPLICATION_CONTROL_SESSION_CLOSE:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.close_application_control_session
|
||||
)
|
||||
if action_id == ACTION_ACQUISITION_PREPARE:
|
||||
prepare_request = PrepareAcquisitionRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,488 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
ApplicationAcceptanceError,
|
||||
PhysicalAcceptanceChecklist,
|
||||
PhysicalAcceptanceDialogueExecutor,
|
||||
PhysicalAcceptancePermit,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
|
||||
ApplicationAuthorityLoader,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
ApplicationCommandOutcomeUnknown,
|
||||
ReviewedApplicationMqttTransport,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
CommandHeaderIdentity,
|
||||
ModelingAction,
|
||||
MountType,
|
||||
RecordMode,
|
||||
ScanMode,
|
||||
encode_modeling_start,
|
||||
encode_modeling_stop,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
ShadowModelingCommand,
|
||||
)
|
||||
|
||||
ApplicationControlPhase = Literal[
|
||||
"idle",
|
||||
"connecting",
|
||||
"connection-ready",
|
||||
"workspace-requested",
|
||||
"workspace-ready",
|
||||
"project-requested",
|
||||
"project-ready",
|
||||
"start-requested",
|
||||
"initializing",
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
"completed",
|
||||
"closed",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperatorPresenceConfirmation:
|
||||
operator_present: Literal[True]
|
||||
owner_controlled_device: Literal[True]
|
||||
lixelgo_closed: Literal[True]
|
||||
battery_storage_confirmed: Literal[True]
|
||||
expected_physical_state_confirmed: Literal[True]
|
||||
|
||||
def checklist(self, action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||
return PhysicalAcceptanceChecklist(
|
||||
action=action,
|
||||
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 InteractiveApplicationControlSession:
|
||||
"""Own one canonical K1 MQTT dialogue across explicit operator UI events.
|
||||
|
||||
Only this background thread touches the MQTT client. UI requests merely
|
||||
release one named checkpoint. No checkpoint is advanced by elapsed time,
|
||||
and neither START nor STOP has an automatic retry path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority_loader: ApplicationAuthorityLoader,
|
||||
*,
|
||||
transport_factory: TransportFactory = ReviewedApplicationMqttTransport,
|
||||
epoch_seconds: Callable[[], int] = lambda: int(time.time()),
|
||||
) -> None:
|
||||
self._authority_loader = authority_loader
|
||||
self._transport_factory = transport_factory
|
||||
self._epoch_seconds = epoch_seconds
|
||||
self._lock = threading.RLock()
|
||||
self._phase: ApplicationControlPhase = "idle"
|
||||
self._host: str | None = None
|
||||
self._timezone_name: str | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._transport: ReviewedApplicationMqttTransport | None = None
|
||||
self._workspace_requested = threading.Event()
|
||||
self._project_requested = threading.Event()
|
||||
self._start_requested = threading.Event()
|
||||
self._stop_requested = threading.Event()
|
||||
self._standby_confirmed = threading.Event()
|
||||
self._cancel_requested = False
|
||||
self._start_confirmation: OperatorPresenceConfirmation | None = None
|
||||
self._stop_confirmation: OperatorPresenceConfirmation | None = None
|
||||
self._project_name: str | None = None
|
||||
self._failure: dict[str, object] | None = None
|
||||
self._dialogue_snapshot: dict[str, object] | None = None
|
||||
self._transport_snapshot: dict[str, object] | None = None
|
||||
self._outcome_unknown = False
|
||||
|
||||
def open(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
timezone_name: str,
|
||||
confirmation: OperatorPresenceConfirmation,
|
||||
) -> dict[str, object]:
|
||||
# Validate every explicit confirmation before creating a network owner.
|
||||
confirmation.checklist(ModelingAction.START)
|
||||
with self._lock:
|
||||
if self._phase in {"completed", "closed"} or (
|
||||
self._phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
):
|
||||
self._reset_locked()
|
||||
if self._phase != "idle":
|
||||
raise ApplicationAcceptanceError(
|
||||
"application control session is already open or requires manual recovery"
|
||||
)
|
||||
self._host = host
|
||||
self._timezone_name = timezone_name
|
||||
self._set_phase_locked("connecting")
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="xgrids-k1-canonical-control",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
return self.snapshot()
|
||||
|
||||
def enter_workspace(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("connection-ready")
|
||||
self._set_phase_locked("workspace-requested")
|
||||
self._workspace_requested.set()
|
||||
return self.snapshot()
|
||||
|
||||
def open_project_prompt(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("workspace-ready")
|
||||
self._set_phase_locked("project-requested")
|
||||
self._project_requested.set()
|
||||
return self.snapshot()
|
||||
|
||||
def request_start(
|
||||
self,
|
||||
*,
|
||||
project_name: str,
|
||||
confirmation: OperatorPresenceConfirmation,
|
||||
) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("project-ready")
|
||||
self._project_name = project_name
|
||||
self._start_confirmation = confirmation
|
||||
self._set_phase_locked("start-requested")
|
||||
self._start_requested.set()
|
||||
return self.snapshot()
|
||||
|
||||
def request_stop(
|
||||
self,
|
||||
*,
|
||||
confirmation: OperatorPresenceConfirmation,
|
||||
) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("scanning")
|
||||
self._stop_confirmation = confirmation
|
||||
self._set_phase_locked("stop-requested")
|
||||
self._stop_requested.set()
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("awaiting-standby-confirmation")
|
||||
if not self._device_ready_for_standby_confirmation_locked():
|
||||
raise ApplicationAcceptanceError(
|
||||
"K1 has not reported unbound READY after canonical STOP"
|
||||
)
|
||||
self._standby_confirmed.set()
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
if self._phase not in {
|
||||
"connection-ready",
|
||||
"workspace-ready",
|
||||
"project-ready",
|
||||
}:
|
||||
raise ApplicationAcceptanceError(
|
||||
"control session can be closed safely only between pre-START checkpoints"
|
||||
)
|
||||
self._cancel_requested = True
|
||||
transport = self._transport
|
||||
if transport is not None:
|
||||
transport.close()
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the process-owned socket without ever inventing a device STOP."""
|
||||
|
||||
with self._lock:
|
||||
self._cancel_requested = True
|
||||
transport = self._transport
|
||||
thread = self._thread
|
||||
if transport is not None:
|
||||
transport.close()
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
transport_snapshot = self._live_transport_snapshot_locked()
|
||||
phase = self._phase
|
||||
return {
|
||||
"mode": "interactive-canonical",
|
||||
"state": phase,
|
||||
"control_socket_open": phase
|
||||
not in {"idle", "completed", "closed", "failed"},
|
||||
"can_open": phase in {"idle", "completed", "closed"}
|
||||
or (
|
||||
phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
),
|
||||
"can_enter_workspace": phase == "connection-ready",
|
||||
"can_prepare_project": phase == "workspace-ready",
|
||||
"can_start": phase == "project-ready",
|
||||
"can_stop": phase == "scanning",
|
||||
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
|
||||
"pending_operator_action": self._pending_operator_action_locked(),
|
||||
"scripted_transitions": False,
|
||||
"automatic_retry": False,
|
||||
"outcome_unknown": self._outcome_unknown,
|
||||
"failure": dict(self._failure) if self._failure is not None else None,
|
||||
"dialogue": (
|
||||
dict(self._dialogue_snapshot)
|
||||
if self._dialogue_snapshot is not None
|
||||
else None
|
||||
),
|
||||
"transport": transport_snapshot,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||
transport: ReviewedApplicationMqttTransport | None = None
|
||||
try:
|
||||
with self._lock:
|
||||
host = self._host
|
||||
timezone_name = self._timezone_name
|
||||
if host is None or timezone_name is None:
|
||||
raise ApplicationAcceptanceError("control session inputs are unavailable")
|
||||
|
||||
authority = self._authority_loader.load()
|
||||
transport = self._transport_factory(host)
|
||||
with self._lock:
|
||||
self._transport = transport
|
||||
transport.open()
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
authority,
|
||||
epoch_seconds=self._epoch_seconds(),
|
||||
timezone_name=timezone_name,
|
||||
)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
self._set_phase("connection-ready")
|
||||
|
||||
workspace = executor.wait_for_operator_checkpoint(
|
||||
"workspace-entered",
|
||||
self._workspace_requested.is_set,
|
||||
)
|
||||
executor.run_workspace_entry_stage(orchestrator, workspace)
|
||||
self._set_phase("workspace-ready")
|
||||
|
||||
project = executor.wait_for_operator_checkpoint(
|
||||
"project-prompt-opened",
|
||||
self._project_requested.is_set,
|
||||
)
|
||||
binding = executor.run_project_prompt_stage(orchestrator, project)
|
||||
self._set_phase("project-ready")
|
||||
|
||||
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
self._start_requested.is_set,
|
||||
)
|
||||
project_name, start_confirmation = self._start_request()
|
||||
start_permit = PhysicalAcceptancePermit(
|
||||
start_confirmation.checklist(ModelingAction.START)
|
||||
)
|
||||
self._set_phase("initializing")
|
||||
executor.execute_canonical_start(
|
||||
self._start_command(authority, binding, project_name),
|
||||
build_canonical_post_start_observation(authority, binding),
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
permit=start_permit,
|
||||
checkpoint=start_checkpoint,
|
||||
)
|
||||
self._set_phase("scanning")
|
||||
|
||||
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||
stop_confirmation = self._stop_request()
|
||||
stop_permit = PhysicalAcceptancePermit(
|
||||
stop_confirmation.checklist(ModelingAction.STOP)
|
||||
)
|
||||
self._set_phase("stopping")
|
||||
executor.execute_canonical_stop(
|
||||
self._stop_command(authority, binding),
|
||||
stop_permit,
|
||||
)
|
||||
self._set_phase("awaiting-standby-confirmation")
|
||||
executor.maintain_post_stop_until_standby_confirmed(
|
||||
self._standby_confirmed.is_set
|
||||
)
|
||||
self._set_phase("completed")
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
if self._cancel_requested:
|
||||
self._set_phase_locked("closed")
|
||||
else:
|
||||
publish_attempts = 0
|
||||
if transport is not None:
|
||||
observed_attempts = transport.snapshot().as_dict().get(
|
||||
"publish_attempts",
|
||||
0,
|
||||
)
|
||||
if isinstance(observed_attempts, int) and not isinstance(
|
||||
observed_attempts,
|
||||
bool,
|
||||
):
|
||||
publish_attempts = observed_attempts
|
||||
outcome_unknown = isinstance(
|
||||
exc, ApplicationCommandOutcomeUnknown
|
||||
) or self._phase in {
|
||||
"start-requested",
|
||||
"initializing",
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
}
|
||||
self._failure = {
|
||||
"code": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
|
||||
}
|
||||
self._outcome_unknown = outcome_unknown
|
||||
self._set_phase_locked("failed")
|
||||
finally:
|
||||
if executor is not None:
|
||||
with self._lock:
|
||||
self._dialogue_snapshot = executor.snapshot()
|
||||
if transport is not None:
|
||||
try:
|
||||
with self._lock:
|
||||
self._transport_snapshot = transport.snapshot().as_dict()
|
||||
finally:
|
||||
transport.close()
|
||||
with self._lock:
|
||||
self._transport = None
|
||||
|
||||
@staticmethod
|
||||
def _start_command(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
project_name: str,
|
||||
) -> ShadowModelingCommand:
|
||||
return ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=authority.openapi_key,
|
||||
),
|
||||
project_name=project_name,
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stop_command(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> ShadowModelingCommand:
|
||||
return ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=authority.openapi_key,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _start_request(self) -> tuple[str, OperatorPresenceConfirmation]:
|
||||
with self._lock:
|
||||
if self._project_name is None or self._start_confirmation is None:
|
||||
raise ApplicationAcceptanceError("START request is incomplete")
|
||||
return self._project_name, self._start_confirmation
|
||||
|
||||
def _stop_request(self) -> OperatorPresenceConfirmation:
|
||||
with self._lock:
|
||||
if self._stop_confirmation is None:
|
||||
raise ApplicationAcceptanceError("STOP request is incomplete")
|
||||
return self._stop_confirmation
|
||||
|
||||
def _set_phase(self, phase: ApplicationControlPhase) -> None:
|
||||
with self._lock:
|
||||
self._set_phase_locked(phase)
|
||||
|
||||
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def _require_phase_locked(self, expected: ApplicationControlPhase) -> None:
|
||||
if self._phase != expected:
|
||||
raise ApplicationAcceptanceError(
|
||||
f"control action requires {expected}; current state is {self._phase}"
|
||||
)
|
||||
|
||||
def _device_ready_for_standby_confirmation_locked(self) -> bool:
|
||||
if self._phase != "awaiting-standby-confirmation":
|
||||
return False
|
||||
snapshot = self._live_transport_snapshot_locked()
|
||||
return (
|
||||
snapshot.get("latest_device_session_state") == "ready"
|
||||
and snapshot.get("latest_device_project_bound") is False
|
||||
)
|
||||
|
||||
def _live_transport_snapshot_locked(self) -> dict[str, object]:
|
||||
if self._transport is not None:
|
||||
return self._transport.snapshot().as_dict()
|
||||
return (
|
||||
dict(self._transport_snapshot)
|
||||
if self._transport_snapshot is not None
|
||||
else {
|
||||
"state": "not-opened",
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
)
|
||||
|
||||
def _pending_operator_action_locked(self) -> str | None:
|
||||
return {
|
||||
"connection-ready": "enter-workspace",
|
||||
"workspace-ready": "prepare-project",
|
||||
"project-ready": "start",
|
||||
"scanning": "stop",
|
||||
"awaiting-standby-confirmation": "confirm-steady-green",
|
||||
}.get(self._phase)
|
||||
|
||||
def _reset_locked(self) -> None:
|
||||
self._phase = "idle"
|
||||
self._host = None
|
||||
self._timezone_name = None
|
||||
self._thread = None
|
||||
self._transport = None
|
||||
self._workspace_requested = threading.Event()
|
||||
self._project_requested = threading.Event()
|
||||
self._start_requested = threading.Event()
|
||||
self._stop_requested = threading.Event()
|
||||
self._standby_confirmed = threading.Event()
|
||||
self._cancel_requested = False
|
||||
self._start_confirmation = None
|
||||
self._stop_confirmation = None
|
||||
self._project_name = None
|
||||
self._failure = None
|
||||
self._dialogue_snapshot = None
|
||||
self._transport_snapshot = None
|
||||
self._outcome_unknown = False
|
||||
|
|
@ -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_does_not_claim_software_controls_the_physical_scanner() -> None:
|
||||
def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
connection_source = (
|
||||
repository_root
|
||||
|
|
@ -125,8 +125,11 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
|
|||
/ "K1AcquisitionPipeline.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
assert "Подготовить локальный приём данных" in connection_source
|
||||
assert "Программная команда запуска на K1 не отправляется" in connection_source
|
||||
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 "Физическое состояние сканера остаётся неизвестным" in connection_source
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
|||
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||
)
|
||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||
assert plugin["metadata"]["version"] == "0.4.0"
|
||||
assert plugin["metadata"]["version"] == "0.5.0"
|
||||
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||
{
|
||||
|
|
@ -119,10 +119,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
|||
"application-control.shadow-state",
|
||||
"application-control.shadow-arm",
|
||||
"application-control.shadow-disarm",
|
||||
"application-control.session.open",
|
||||
"application-control.workspace.enter",
|
||||
"application-control.session.close",
|
||||
} <= action_ids
|
||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"pluginVersion": "0.4.0",
|
||||
"pluginVersion": "0.5.0",
|
||||
"id": "xgrids.lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"displayName": "XGRIDS LixelKity K1",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
|||
CameraPreviewSelectRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
OperatorPresenceRequest,
|
||||
PrepareAcquisitionRequest,
|
||||
ShadowApplicationControlArmRequest,
|
||||
StartAcquisitionRequest,
|
||||
|
|
@ -113,6 +114,61 @@ class FakeVisualizationRuntime:
|
|||
self.stop()
|
||||
|
||||
|
||||
class FakeInteractiveControlSession:
|
||||
def __init__(self) -> None:
|
||||
self.state = "workspace-ready"
|
||||
self.start_projects: list[str] = []
|
||||
self.stop_calls = 0
|
||||
self.confirm_calls = 0
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
|
||||
}
|
||||
|
||||
def open_project_prompt(self) -> dict[str, object]:
|
||||
assert self.state == "workspace-ready"
|
||||
self.state = "project-ready"
|
||||
return self.snapshot()
|
||||
|
||||
def request_start(self, *, project_name: str, confirmation: object) -> dict[str, object]:
|
||||
assert confirmation is not None
|
||||
assert self.state == "project-ready"
|
||||
self.start_projects.append(project_name)
|
||||
self.state = "scanning"
|
||||
return self.snapshot()
|
||||
|
||||
def request_stop(self, *, confirmation: object) -> dict[str, object]:
|
||||
assert confirmation is not None
|
||||
assert self.state == "scanning"
|
||||
self.stop_calls += 1
|
||||
self.state = "awaiting-standby-confirmation"
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
assert self.state == "awaiting-standby-confirmation"
|
||||
self.confirm_calls += 1
|
||||
self.state = "completed"
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
self.state = "closed"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
self.state = "closed"
|
||||
|
||||
|
||||
PHYSICAL_ACCEPTANCE = OperatorPresenceRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
|
||||
def service_with_fake_runtime(
|
||||
tmp_path: Path,
|
||||
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
|
||||
|
|
@ -291,6 +347,68 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa
|
|||
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
|
||||
|
||||
|
||||
def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
control = FakeInteractiveControlSession()
|
||||
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name="TEST001",
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
assert prepared["acquisition"]["control_mode"] == "plugin-commanded"
|
||||
assert control.state == "project-ready"
|
||||
assert runtime.start_calls == []
|
||||
|
||||
with pytest.raises(ValueError, match="подтверждения присутствия"):
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
assert control.start_projects == []
|
||||
|
||||
service.start_acquisition(
|
||||
StartAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
assert control.start_projects == ["TEST001"]
|
||||
runtime.mark_ready()
|
||||
runtime.pcl_frames = 1
|
||||
acquiring = service.state()
|
||||
assert acquiring["acquisition"]["state"] == "acquiring"
|
||||
|
||||
stopping = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
stop_operation = stopping["last_operation"]
|
||||
assert control.stop_calls == 1
|
||||
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
||||
|
||||
completed = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
operator_confirmed=True,
|
||||
operation_id=stop_operation["operation_id"],
|
||||
)
|
||||
)
|
||||
assert control.stop_calls == 1
|
||||
assert control.confirm_calls == 1
|
||||
assert completed["acquisition"]["state"] == "completed"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
|
||||
InteractiveApplicationControlSession,
|
||||
OperatorPresenceConfirmation,
|
||||
)
|
||||
|
||||
APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
class FakeAuthorityLoader:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
self.calls += 1
|
||||
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTransportSnapshot:
|
||||
state: str
|
||||
ready: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, host: str) -> None:
|
||||
self.host = host
|
||||
self.state = "new"
|
||||
self.ready = True
|
||||
|
||||
def open(self) -> FakeTransportSnapshot:
|
||||
self.state = "ready"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
self.state = "closed"
|
||||
|
||||
def snapshot(self) -> FakeTransportSnapshot:
|
||||
return FakeTransportSnapshot(self.state, self.ready)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
records: list[str] = []
|
||||
|
||||
def __init__(self, transport: FakeTransport) -> None:
|
||||
self.transport = transport
|
||||
self.binding = LiveDeviceControlBinding(
|
||||
vendor_device_id="device-id",
|
||||
device_serial="serial-id",
|
||||
software_version="3.0.2",
|
||||
system_version="3.0.2",
|
||||
device_model="K1",
|
||||
device_type="scanner",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
|
||||
self.records.append("connection:1-6")
|
||||
return self.binding
|
||||
|
||||
def wait_for_operator_checkpoint(
|
||||
self,
|
||||
event: str,
|
||||
observed: Any,
|
||||
) -> str:
|
||||
self.records.append(f"wait:{event}")
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not observed():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"test did not release {event}")
|
||||
threading.Event().wait(0.005)
|
||||
return event
|
||||
|
||||
def run_workspace_entry_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
_checkpoint: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
self.records.append("workspace:7")
|
||||
return self.binding
|
||||
|
||||
def run_project_prompt_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
_checkpoint: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
self.records.append("project:8-10")
|
||||
return self.binding
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("start:11-14")
|
||||
self.transport.ready = False
|
||||
return object()
|
||||
|
||||
def maintain_active_until_stop_requested(self, observed: Any) -> None:
|
||||
self.records.append("wait:stop")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
|
||||
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("stop")
|
||||
self.transport.ready = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||
self.records.append("wait:steady-green")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"records": tuple(self.records), "automatic_retry": False}
|
||||
|
||||
|
||||
def _confirmation() -> OperatorPresenceConfirmation:
|
||||
return OperatorPresenceConfirmation(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
|
||||
def _wait_phase(
|
||||
session: InteractiveApplicationControlSession,
|
||||
expected: str,
|
||||
) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = session.snapshot()
|
||||
if snapshot["state"] == expected:
|
||||
return snapshot
|
||||
threading.Event().wait(0.005)
|
||||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||
|
||||
|
||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
FakeExecutor,
|
||||
)
|
||||
loader = FakeAuthorityLoader()
|
||||
transport = FakeTransport("192.168.1.20")
|
||||
session = InteractiveApplicationControlSession(
|
||||
loader,
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
epoch_seconds=lambda: 1_752_680_000,
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "connection-ready")
|
||||
threading.Event().wait(0.02)
|
||||
assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]
|
||||
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
assert FakeExecutor.records[-1] == "wait:project-prompt-opened"
|
||||
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
assert FakeExecutor.records[-1] == "wait:start-confirmed"
|
||||
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||
assert standby["can_confirm_standby"] is True
|
||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||
|
||||
session.confirm_standby()
|
||||
completed = _wait_phase(session, "completed")
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scripted_transitions"] is False
|
||||
assert FakeExecutor.records == [
|
||||
"connection:1-6",
|
||||
"wait:workspace-entered",
|
||||
"workspace:7",
|
||||
"wait:project-prompt-opened",
|
||||
"project:8-10",
|
||||
"wait:start-confirmed",
|
||||
"start:11-14",
|
||||
"wait:stop",
|
||||
"stop",
|
||||
"wait:steady-green",
|
||||
]
|
||||
Loading…
Reference in New Issue