From 7b7b5d6cadca21ef17b1b9ca3b027e8c18f99e8e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 18 Jul 2026 17:53:55 +0300 Subject: [PATCH] feat(k1): wire canonical control session to UI --- README.md | 22 +- apps/control-station/README.md | 13 +- docs/01_IMPLEMENTATION_PLAN.md | 2 +- docs/05_K1_MQTT_STREAM_PROFILE.md | 9 +- docs/06_K1_LIVE_VIEWER.md | 55 +- docs/07_MISSION_CORE_MONOREPO.md | 21 +- ...-plugin-owned-frontend-device-workflows.md | 7 +- .../0012-device-bound-k1-command-authority.md | 18 +- plugins/xgrids-k1/README.md | 41 +- plugins/xgrids-k1/frontend/README.md | 10 +- plugins/xgrids-k1/frontend/src/api.ts | 77 +++ .../src/components/K1AcquisitionPipeline.tsx | 210 ++++++-- .../src/components/K1SpatialControls.tsx | 40 +- plugins/xgrids-k1/frontend/src/manifest.ts | 12 + .../xgrids-k1/frontend/src/runtimeContext.tsx | 8 +- .../frontend/src/useXgridsK1Runtime.ts | 91 +++- plugins/xgrids-k1/plugin.manifest.json | 8 +- plugins/xgrids-k1/profiles/README.md | 15 +- src/k1link/device_plugins/xgrids_k1/facade.py | 276 +++++++++- .../xgrids_k1/protocol/application_session.py | 488 ++++++++++++++++++ tests/test_frontend_plugin_boundaries.py | 9 +- tests/test_plugin_catalog.py | 7 +- tests/test_xgrids_acquisition_lifecycle.py | 118 +++++ tests/test_xgrids_application_session.py | 218 ++++++++ 24 files changed, 1591 insertions(+), 184 deletions(-) create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/application_session.py create mode 100644 tests/test_xgrids_application_session.py diff --git a/README.md b/README.md index 808218c..9405dfe 100644 --- a/README.md +++ b/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 diff --git a/apps/control-station/README.md b/apps/control-station/README.md index 774fd16..736aa64 100644 --- a/apps/control-station/README.md +++ b/apps/control-station/README.md @@ -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, diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index b2e677f..efc01a8 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -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 diff --git a/docs/05_K1_MQTT_STREAM_PROFILE.md b/docs/05_K1_MQTT_STREAM_PROFILE.md index 4dc4b1c..56048e8 100644 --- a/docs/05_K1_MQTT_STREAM_PROFILE.md +++ b/docs/05_K1_MQTT_STREAM_PROFILE.md @@ -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 diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md index c4bf48c..dd3d726 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -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 diff --git a/docs/07_MISSION_CORE_MONOREPO.md b/docs/07_MISSION_CORE_MONOREPO.md index 88e04b5..495827b 100644 --- a/docs/07_MISSION_CORE_MONOREPO.md +++ b/docs/07_MISSION_CORE_MONOREPO.md @@ -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 diff --git a/docs/adr/0010-plugin-owned-frontend-device-workflows.md b/docs/adr/0010-plugin-owned-frontend-device-workflows.md index d121c0d..905c421 100644 --- a/docs/adr/0010-plugin-owned-frontend-device-workflows.md +++ b/docs/adr/0010-plugin-owned-frontend-device-workflows.md @@ -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. diff --git a/docs/adr/0012-device-bound-k1-command-authority.md b/docs/adr/0012-device-bound-k1-command-authority.md index 2fdf285..6159123 100644 --- a/docs/adr/0012-device-bound-k1-command-authority.md +++ b/docs/adr/0012-device-bound-k1-command-authority.md @@ -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 diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md index 347e400..ec34594 100644 --- a/plugins/xgrids-k1/README.md +++ b/plugins/xgrids-k1/README.md @@ -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 diff --git a/plugins/xgrids-k1/frontend/README.md b/plugins/xgrids-k1/frontend/README.md index 0d2c302..6b53013 100644 --- a/plugins/xgrids-k1/frontend/README.md +++ b/plugins/xgrids-k1/frontend/README.md @@ -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. diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 0ae19b7..e9f9002 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -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 | null; + transport?: Record | 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 { return invokeState(xgridsK1Actions.applicationControlShadowDisarm); }, + + openApplicationControlSession( + body: OpenApplicationControlSessionRequest, + ): Promise { + return invokeState(xgridsK1Actions.applicationControlSessionOpen, body); + }, + + enterApplicationWorkspace( + body: EnterApplicationWorkspaceRequest, + ): Promise { + return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body); + }, + + closeApplicationControlSession(): Promise { + return invokeState(xgridsK1Actions.applicationControlSessionClose); + }, }; export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx index 767a61b..21432b7 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -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("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(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" ? (
- { - 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" - /> - setLiveHost(event.target.value)} - disabled={sessionLocked} - spellCheck={false} - placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"} - /> - + {(control?.can_open ?? controlPhase === "idle") ? ( + <> + + + + ) : null} + {controlPhase === "connection-ready" ? ( + + ) : null} + {["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? ( + <> + { + 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" + /> + setLiveHost(event.target.value)} + disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null} + spellCheck={false} + placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"} + /> + + ) : null} + {controlPhase === "workspace-ready" && !preparedAcquisition ? ( + + ) : null} + {controlPhase === "project-ready" && preparedAcquisition ? ( + + ) : null} + {["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? ( + + ) : null}

{!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 и инициализацию. Остановка доступна в пространственной сцене." + : "Каждый этап канонического диалога запускается отдельным действием оператора."}

) : ( @@ -219,7 +312,22 @@ export function K1AcquisitionPipeline({ : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." : "Активного источника сейчас нет."}

- + ) : null} + + {state?.application_control_session?.can_confirm_standby ? ( + + ) : null} ); } diff --git a/plugins/xgrids-k1/frontend/src/manifest.ts b/plugins/xgrids-k1/frontend/src/manifest.ts index 736dcd2..6e1e587 100644 --- a/plugins/xgrids-k1/frontend/src/manifest.ts +++ b/plugins/xgrids-k1/frontend/src/manifest.ts @@ -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", + ), }); diff --git a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx index cc1168d..aed73a1 100644 --- a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx +++ b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx @@ -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, ]); diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index e7d4467..e2bb3f9 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -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, diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index ba49787..439404e 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -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": [ { diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md index 0d3f9ed..402ac8f 100644 --- a/plugins/xgrids-k1/profiles/README.md +++ b/plugins/xgrids-k1/profiles/README.md @@ -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 diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index a9c2988..62dbd99 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -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) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py new file mode 100644 index 0000000..04cda95 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py @@ -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 diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index 44daf89..b0cf2ee 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -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 diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index e75d748..b95e288 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -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", diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 2683236..af7f27b 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -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( diff --git a/tests/test_xgrids_application_session.py b/tests/test_xgrids_application_session.py new file mode 100644 index 0000000..c2095c8 --- /dev/null +++ b/tests/test_xgrids_application_session.py @@ -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", + ]