feat(k1): wire canonical control session to UI

This commit is contained in:
DCCONSTRUCTIONS 2026-07-18 17:53:55 +03:00
parent 36f0c93d2a
commit 7b7b5d6cad
24 changed files with 1591 additions and 184 deletions

View File

@ -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 reviewed-TSV replay, raw-first evidence storage, device-reported scan
time/distance/speed and measured preview metrics. Its frontend contribution now time/distance/speed and measured preview metrics. Its frontend contribution now
also owns an optional spatial-scene control block, including acquisition phase, also owns an optional spatial-scene control block, including acquisition phase,
telemetry and the stop action. On the active laboratory profile that stop action telemetry and the stop action. Operator-manual acquisition still finalizes only
finalizes local reception only; physical K1 scanning is still started and local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
stopped by the verified double-click. canonical K1 START/STOP dialogue.
The exact recovered `ModelingRequest` start/stop encoder, response correlator The exact recovered `ModelingRequest` start/stop encoder, response correlator
and device-status state machine are implemented as inert protocol components. 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 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 byte-matched offline. A fixed macOS Keychain loader now supplies the exact
application authority without env/file/browser fallback, and a five-batch application authority without env/file/browser fallback, and a five-batch
orchestrator passes all retained response barriers. The installed publisher orchestrator passes all retained response barriers. The legacy shadow publisher
boundary is structurally write-disabled and cannot call its sink. Plugin v0.4.0 boundary remains structurally write-disabled and cannot call its sink. Its
wires a dormant coordinator into the facade: explicit shadow arm holds authority dormant coordinator can hold authority
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
it together with the orchestrator. A separate, uninstalled physical-acceptance it together with the orchestrator. Plugin v0.5.0 installs a separate
transport now reproduces MQTT 3.1.1 `clean_session=false`, keepalive 60, exact physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
keepalive 60, exact
response subscriptions, QoS2 completion and the five response-gated batches. response subscriptions, QoS2 completion and the five response-gated batches.
It consumes operation keys before publish and poisons unknown outcomes without It consumes operation keys before publish and poisons unknown outcomes without
retry. Neither component is wired to facade/UI; `vendor_writes_enabled` remains retry. One background owner services that socket between separate connection,
false. The first operator-present physical attempt on 2026-07-18 emitted only 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 the first six bootstrap requests, then failed closed while correlating the third
response batch; START was not emitted. A second one-shot attempt correlated all response batch; START was not emitted. A second one-shot attempt correlated all
ten pre-START requests and the START acknowledgement, but incorrectly collapsed ten pre-START requests and the START acknowledgement, but incorrectly collapsed

View File

@ -322,12 +322,13 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
очищается после успешного ответа и не сохраняется в URL/local storage. очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись; - BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
случайные GATT writes и автоматические повторы запрещены. случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка - MQTT data live/replay остаётся subscribe-only. Команды изолированы в
физического сканирования остаются за кнопкой K1. plugin-owned canonical control session и доступны только через отдельные
- Exact start/stop codec, response correlator и device-status state machine operator-present UI checkpoints.
остаются inert: они не импортируют MQTT transport. Header derivation известен, - Exact start/stop codec, response correlator и device-status state machine не
но legitimate OpenAPI key provisioning и durable post-stop save gate не владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
доказаны, поэтому `vendor_writes_enabled=false`. контуром без wall-clock переходов, reconnect или automatic retry; после STOP
обязательны live READY и подтверждённый постоянный зелёный индикатор.
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем - Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен, формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO, native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,

View File

@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented | | Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet | | Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | 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 | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the USB project copying remains optional ground truth rather than a blocker for the

View File

@ -260,7 +260,7 @@ operator power cycle restored steady green.
This disproves the earlier assumption that byte-matched pre-START payloads plus This disproves the earlier assumption that byte-matched pre-START payloads plus
a correlated START acknowledgement are sufficient acceptance. The legacy a correlated START acknowledgement are sufficient acceptance. The legacy
collapsed `run_bootstrap()` executor now rejects before emission. Its 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 observed connection, workspace-entry and project-prompt checkpoints instead of
wall-clock floors, sends the immediate post-START ModelingStatus read, services wall-clock floors, sends the immediate post-START ModelingStatus read, services
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the 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 state; a fault forbids further automatic action. Standalone START/STOP are
disabled. MQTT keepalive/report processing also continues while the operator is disabled. MQTT keepalive/report processing also continues while the operator is
between UI actions; the 15120-second command permit is created at the actual between UI actions; the 15120-second command permit is created at the actual
START or STOP confirmation, not at connection time. This path is START or STOP confirmation, not at connection time. Plugin v0.5.0 now exposes
synthetic-test-only and remains outside facade/UI, so it this path through separate plugin-owned UI actions. Repository tests use only
does not authorize a new physical attempt. 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 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 argument so Apple's own TTY prompt receives the authority. Mission Core never

View File

@ -115,44 +115,45 @@ directly and use their sibling metadata receive timestamps when present.
complete visible-device list. complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed 4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically. provisioning write. The backend does not retry the write automatically.
5. Enter the required project name. Mission Core NFKC-normalizes and trims it, 5. Confirm operator presence, closed LixelGO, storage/power and steady green;
rejects control/surrogate characters or more than 96 characters, and stores choose **Подключить управление K1**. This emits only connection operations
it as local display metadata rather than a path. The read-only profile does 16 and then waits indefinitely for the next UI action.
not send it to K1. 6. Choose **Открыть рабочее пространство K1** for operation 7.
6. When K1 reports a non-AP private address, choose **Подготовить локальный 7. Enter the required project name and choose **Сохранить проект и подготовить
приём данных**. The stronger “initiate device work” wording appears only for локальный приём**. Mission Core normalizes it, prepares evidence reception
a future profile that actually authorizes a vendor write. and releases only operations 810; the name itself is not sent yet.
7. Wait until the UI reports that the local Rerun bridge is ready. No manual 8. After the project step is ready, choose **Запустить сканирование K1** once.
viewer URL is needed. The project name is carried by canonical START. Mission Core opens
8. Open **Наблюдение → Пространственная сцена**. The K1 plugin's optional scene **Наблюдение → Пространственная сцена** and shows calibration while the same
block shows the local preparation/acquisition phase and stop action. control socket waits for `SCANNING + project + init_ready`.
9. Double-click the physical K1 button to start scanning. Real point frames and 9. Real point frames and pose/trajectory updates then appear in the embedded
pose/trajectory updates then appear in the embedded viewport. 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 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 time, route distance and speed. These are device reports, not values inferred
from a browser timer or accumulated across a device counter reset. from a browser timer or accumulated across a device counter reset.
11. Double-click K1 again to stop physical scanning and wait for steady green, 11. Choose **Остановить устройство и запись** in the scene. The
then choose **Остановить локальный приём** in the scene or device workflow plugin-commanded path emits one canonical STOP on the original socket,
so capture, camera archive and summaries are finalized. That UI action does waits for live unbound READY, and asks the operator to confirm the constant
not stop K1 on the active profile. green indicator before capture, camera archive and summaries are finalized.
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames, `.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
per-message metadata and a hash summary. Repository-level per-message metadata and a hash summary. Repository-level
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by `sessions/*_viewer_live` is legacy import-only evidence and is never selected by
the current writer. The connector subscribes to the fixed report-topic the current writer. The data connector remains subscribe-only. Application
allowlist and does not publish an application request or modeling command. 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 observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
responses, and classifies live device states without importing MQTT or responses, and classifies live device states before any command transition. One
publishing anything. One private application-level OpenAPI authority is kept private application-level OpenAPI authority is kept
separate from the transient vendor ID/serial returned by the BLE-selected K1. separate from the transient vendor ID/serial returned by the BLE-selected K1.
The fixed Keychain loader, bounded execution lease and dormant facade The fixed Keychain loader and dormant shadow coordinator remain available for
orchestrator now exist. No emission method or live MQTT sink is installed, and inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT
an acceptance-only transport exists outside the facade. Keychain provisioning, owner: operations 16 at connection, 7 at workspace entry, 810 at project
physical START/STOP and durable save completion remain open, so preparation, then separately permitted START and STOP. Physical START/STOP and
`vendor_writes_enabled` is false and the physical-button workflow is canonical. durable native-project completion still require operator acceptance.
## Automatic Rerun source and lifecycle ## Automatic Rerun source and lifecycle

View File

@ -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 and records the basis as `operator-attested`. That claim is not device-derived
proof. The profile independently records observation, decode, replay, proof. The profile independently records observation, decode, replay,
physical-verification and write evidence. Loading it cannot authorize a physical-verification and write evidence. Loading it cannot authorize a
transport mutation. Application-command publishing remains disabled; the transport mutation. BLE Wi-Fi provisioning and application control retain
separately reviewed BLE Wi-Fi provisioning write retains its own explicit separate explicit operator gates.
operator gate.
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 plugin. It byte-matches the retained ten-request pre-START dialogue plus
START/STOP, correlates live DeviceInfo/modeling responses and maps bounded 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 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 BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator single interactive MQTT session from connection through STOP/standby, with one
are present, but there is no emission method or live MQTT sink. Keychain item explicit UI action per recorded lifecycle boundary and no automatic retry. A
provisioning and stable-artifact proof after STOP remain unresolved. A separate fixed Keychain loader and dormant shadow coordinator remain present. Stable
acceptance-only transport is not imported by the facade, so physical proof after STOP remains unresolved; write capability is exposed only
`vendor_writes_enabled=false` remains authoritative. while that interactive socket owner is active.
## Semantic lifecycle ## Semantic lifecycle

View File

@ -66,13 +66,16 @@ performs no implicit stop.
K1 project names are required, NFKC-normalized and trimmed, rejected for control 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 or surrogate characters or more than 96 Unicode characters, and sent to the
backend as local display metadata. The active profile has backend as local display metadata. At the time of this ADR the active profile
`vendor_writes_enabled=false`, so its had `vendor_writes_enabled=false`, so its
buttons and scene control say that they prepare/stop local reception. Before an buttons and scene control say that they prepare/stop local reception. Before an
acquisition exists, device-start wording is gated by active-control capability; acquisition exists, device-start wording is gated by active-control capability;
after start, calibration/device-stop wording additionally requires a after start, calibration/device-stop wording additionally requires a
plugin-commanded acquisition mode. 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 Plugin CSS is imported with the contribution and scoped below
`.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or `.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or
unscoped global selectors. unscoped global selectors.

View File

@ -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 request-emission method and the installed sink is explicitly uninstalled; this
runtime wiring does not widen write authority. runtime wiring does not widen write authority.
`application_mqtt.py` and `application_acceptance.py` provide the next, still `application_mqtt.py` and `application_acceptance.py` provide the reviewed
uninstalled physical-acceptance boundary. The MQTT type repeats the retained physical-acceptance boundary. The MQTT type repeats the retained
3.1.1 session (`clean_session=false`, keepalive 60), subscribes only to exact 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 known response topics, admits only reviewed request topics and QoS2/retain-false
envelopes, and consumes explicit bootstrap ordinals/modeling actions before the 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 standby. Fault/status observations are redacted and any fault forbids automatic
continuation. The socket remains serviced during operator/UI waits, and each continuation. The socket remains serviced during operator/UI waits, and each
short action permit is created at its button confirmation rather than at 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, connection time.
declared as plugin actions or represented by UI controls.
Plugin v0.5.0 installs this boundary through
`application_session.py`. One background owner is the only thread allowed to
touch the control MQTT client. REST/UI actions release exactly one checkpoint:
session open (16), workspace entry (7), project preparation (810), START, STOP
and steady-green confirmation. Preparing local reception happens before the
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 `MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
`security add-generic-password ... -w` prompt with `-w` last. The private value `security add-generic-password ... -w` prompt with `-w` last. The private value

View File

@ -21,7 +21,8 @@ The plugin owns:
- BLE discovery hints and K1 GATT metadata; - BLE discovery hints and K1 GATT metadata;
- the reviewed firmware-3 Wi-Fi provisioning profile; - the reviewed firmware-3 Wi-Fi provisioning profile;
- K1 LAN status and private-address validation; - 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; - native `.k1mqtt` capture;
- firmware-scoped protobuf/LZ4 and legacy codecs; - firmware-scoped protobuf/LZ4 and legacy codecs;
- normalization of K1 point cloud and pose, plus raw-only preservation of the - 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. - K1-specific operator instructions and compatibility tests.
- the scoped React `device.connection` contribution and its BLE/Wi-Fi and - the scoped React `device.connection` contribution and its BLE/Wi-Fi and
acquisition pipeline UI. acquisition pipeline UI.
- the dormant application-control facade boundary: explicit shadow arm/disarm, - the interactive canonical application-control session: one socket owner,
a 15300 second Keychain-backed in-memory authority lease, and redacted state. separate workspace/project/START/STOP UI checkpoints, live status gates,
single-action physical permits and no automatic retry.
The plugin does not own: 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 `XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
run outside the FastAPI event loop. run outside the FastAPI event loop.
Model switching calls the plugin deactivation hook. An active acquisition uses Model switching calls the plugin deactivation hook. It is rejected while the
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2 canonical K1 control socket is open, so navigation can never become an implicit
sessions retain the legacy `stream.stop` shim. Neither path claims that the device STOP. Operator-manual acquisition uses semantic `acquisition.stop` in
physical K1 stopped without separate operator evidence. BLE, MQTT, codec, `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 archive discovery and RRD export modules now live under the plugin package
after replay parity and physical K1 regression. The current transport remains after replay parity and physical K1 regression. The current transport remains
in-process and its health is lifecycle-only; process isolation, crash/restart 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 uv run python plugins/xgrids-k1/profile_loader.py
``` ```
Plugin v0.4.0 does not widen device authority. Its application-control Plugin v0.5.0 installs that reviewed transport behind explicit plugin actions.
coordinator has no request-emission method, no live MQTT sink and no UI control; Opening control performs only the connection-owned operations 16. Workspace
`vendor_writes_enabled` remains false. A separate uninstalled acceptance-only entry releases operation 7; saving the project and preparing local reception
transport now implements exact subscriptions, QoS2 completion, response releases operations 810; a separate START click carries the project name and
barriers and no-retry poisoning. The admin CLI can provision the fixed Keychain then waits for bound `SCANNING + project + init_ready` before operations 1314.
item through Apple's hidden prompt without accepting the value as an argument. STOP is separately permitted, never retried, and keeps the same socket until K1
Neither path is imported by the facade. The first physical attempt emitted only reports unbound READY and the operator confirms a steady green indicator. The
bootstrap ordinals 16 and failed closed on live batch-3 response correlation; admin CLI still provisions the fixed Keychain item through Apple's hidden
START was not emitted and the K1 remained READY. The temporary lab Keychain item prompt without accepting the private value as an argument. No physical command
was deleted. An OS-independent plugin/edge authority provider, redacted live is emitted merely by loading the plugin, opening the page, navigating, polling
response comparison and a newly permitted full START/STOP acceptance remain state or running repository tests. Full v0.5.0 physical acceptance remains an
required. operator-run gate.
The optional owner-controlled iPhone/LixelGO observation tool lives under The optional owner-controlled iPhone/LixelGO observation tool lives under
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a [`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a

View File

@ -8,12 +8,14 @@ The contribution contains:
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the - `K1ProvisioningPipeline` for power confirmation, BLE discovery and the
reviewed Wi-Fi provisioning write; reviewed Wi-Fi provisioning write;
- `K1AcquisitionPipeline` for live receiver preparation, operator-manual K1 - `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
start/stop and compatibility file replay; 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, - plugin-local diagnostics, metrics, API state, lifecycle mapping,
observation-source mapping and scoped styles; observation-source mapping and scoped styles;
- typed v0.4.0 shadow application-control state/arm/disarm contracts. No button - typed v0.5.0 interactive application-control state plus legacy shadow
is rendered because the plugin has no live command transport; inspection contracts;
- `plugin.ts`, which binds the manifest `device.connection` component key to - `plugin.ts`, which binds the manifest `device.connection` component key to
the runtime provider and connection view. the runtime provider and connection view.

View File

@ -104,6 +104,47 @@ export interface XgridsApplicationControlExecution {
can_emit_requests: false; can_emit_requests: false;
} }
export type XgridsApplicationControlPhase =
| "idle"
| "connecting"
| "connection-ready"
| "workspace-requested"
| "workspace-ready"
| "project-requested"
| "project-ready"
| "start-requested"
| "initializing"
| "scanning"
| "stop-requested"
| "stopping"
| "awaiting-standby-confirmation"
| "completed"
| "closed"
| "failed";
export interface XgridsApplicationControlSession {
mode: "interactive-canonical";
state: XgridsApplicationControlPhase;
control_socket_open: boolean;
can_open: boolean;
can_enter_workspace: boolean;
can_prepare_project: boolean;
can_start: boolean;
can_stop: boolean;
can_confirm_standby: boolean;
pending_operator_action?: string | null;
scripted_transitions: false;
automatic_retry: false;
outcome_unknown: boolean;
failure?: {
code?: string;
message?: string;
safe_to_retry?: boolean;
} | null;
dialogue?: Record<string, unknown> | null;
transport?: Record<string, unknown> | null;
}
export interface XgridsAcquisition { export interface XgridsAcquisition {
schema_version?: string; schema_version?: string;
acquisition_id: string; acquisition_id: string;
@ -228,6 +269,7 @@ export interface XgridsK1State {
compatibility?: XgridsCompatibilityState | null; compatibility?: XgridsCompatibilityState | null;
modeling_control_safety?: XgridsModelingControlSafety | null; modeling_control_safety?: XgridsModelingControlSafety | null;
application_control_execution?: XgridsApplicationControlExecution | null; application_control_execution?: XgridsApplicationControlExecution | null;
application_control_session?: XgridsApplicationControlSession | null;
device_ref?: XgridsDeviceRef | null; device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null; device_session?: XgridsDeviceSession | null;
acquisition?: XgridsAcquisition | null; acquisition?: XgridsAcquisition | null;
@ -288,6 +330,7 @@ export interface StartAcquisitionRequest {
operation_id?: string; operation_id?: string;
idempotency_key?: string; idempotency_key?: string;
deadline_seconds?: number; deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
} }
export interface StopAcquisitionRequest { export interface StopAcquisitionRequest {
@ -297,6 +340,7 @@ export interface StopAcquisitionRequest {
operation_id?: string; operation_id?: string;
idempotency_key?: string; idempotency_key?: string;
deadline_seconds?: number; deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
} }
export interface AbortAcquisitionRequest { export interface AbortAcquisitionRequest {
@ -335,6 +379,23 @@ export interface ShadowApplicationControlArmRequest {
timezone_name: string; 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 { export class ApiError extends Error {
readonly status: number; readonly status: number;
@ -496,6 +557,22 @@ export const xgridsK1Api = {
disarmShadowApplicationControl(): Promise<XgridsK1State> { disarmShadowApplicationControl(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowDisarm); return invokeState(xgridsK1Actions.applicationControlShadowDisarm);
}, },
openApplicationControlSession(
body: OpenApplicationControlSessionRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlSessionOpen, body);
},
enterApplicationWorkspace(
body: EnterApplicationWorkspaceRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body);
},
closeApplicationControlSession(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlSessionClose);
},
}; };
export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; export type EventSocketStatus = "connecting" | "open" | "closed" | "error";

View File

@ -14,6 +14,7 @@ import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart"; import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import { import {
isConfirmedLiveState, isConfirmedLiveState,
isSoftwareCommandedAcquisition,
isSourceRuntimeBusy, isSourceRuntimeBusy,
isVendorWriteCapable, isVendorWriteCapable,
recoverableAcquisition, recoverableAcquisition,
@ -21,6 +22,7 @@ import {
} from "../lifecycle"; } from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName"; import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext"; import type { XgridsK1Controller } from "../runtimeContext";
import type { OperatorPresenceConfirmation } from "../api";
type SessionIntent = "live" | "replay"; type SessionIntent = "live" | "replay";
@ -29,6 +31,14 @@ const sessionItems = [
{ value: "replay", label: "Повтор записи" }, { value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>; ] 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({ export function K1AcquisitionPipeline({
controller, controller,
profileConfirmed, profileConfirmed,
@ -43,9 +53,14 @@ export function K1AcquisitionPipeline({
const { const {
state, state,
pendingAction, pendingAction,
prepareAndStartAcquisition, openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay, startReplay,
stop, stop,
confirmStoppedAtSteadyGreen,
abort, abort,
} = controller; } = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live"); const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@ -55,12 +70,15 @@ export function K1AcquisitionPipeline({
const [replayPath, setReplayPath] = useState(""); const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1"); const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false); const [replayLoop, setReplayLoop] = useState(false);
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
const hydratedAcquisitionId = useRef<string | null>(null); const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state); const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null; const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName); const projectNameValidation = validateProjectName(projectName);
const vendorWriteCapable = isVendorWriteCapable(state); const vendorWriteCapable = isVendorWriteCapable(state);
const control = state?.application_control_session;
const controlPhase = control?.state ?? "idle";
useEffect(() => { useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") { if (state?.source_mode === "live" || state?.source_mode === "replay") {
@ -103,16 +121,29 @@ export function K1AcquisitionPipeline({
[sessionLocked], [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); setProjectNameTouched(true);
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return; if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim(); const targetHost = liveHost.trim();
await prepareAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
};
const startLive = async () => {
await runAutomaticSpatialSourceStart( await runAutomaticSpatialSourceStart(
() => prepareAndStartAcquisition({ () => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
}),
activateAutomaticSpatialSource, activateAutomaticSpatialSource,
openSpatialScene, openSpatialScene,
); );
@ -148,52 +179,114 @@ export function K1AcquisitionPipeline({
/> />
{effectiveSessionIntent === "live" ? ( {effectiveSessionIntent === "live" ? (
<div className="session-form"> <div className="session-form">
<TextField {(control?.can_open ?? controlPhase === "idle") ? (
label="Название проекта" <>
hint="Обязательное поле · до 96 символов" <Checker
value={projectName} checked={physicalAcceptanceConfirmed}
onChange={(event) => { label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
setProjectName(event.target.value); onChange={setPhysicalAcceptanceConfirmed}
setProjectNameTouched(true); />
}} <Button
onBlur={() => setProjectName((value) => normalizeProjectName(value))} variant="primary"
disabled={sessionLocked} icon={<Icon name="activity" />}
autoComplete="off" disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed}
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined} onClick={() => void openControl()}
description={projectNameTouched && projectNameValidation.error >
? projectNameValidation.error {pendingAction === "control"
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."} ? "Открываем канонический диалог…"
placeholder="Например, Испытание маршрута 01" : "Подключить управление K1"}
/> </Button>
<TextField </>
label="Адрес устройства" ) : null}
hint="Обычно определяется автоматически" {controlPhase === "connection-ready" ? (
value={liveHost} <Button
onChange={(event) => setLiveHost(event.target.value)} variant="primary"
disabled={sessionLocked} disabled={isBusy}
spellCheck={false} onClick={() => void enterApplicationWorkspace()}
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к WiFi"} >
/> {pendingAction === "control"
<Button ? "Открываем рабочее пространство…"
variant="primary" : "Открыть рабочее пространство K1"}
icon={<Icon name="activity" />} </Button>
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)} ) : null}
onClick={() => void submitLive()} {["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? (
> <>
{pendingAction === "live" <TextField
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…" label="Название проекта"
: vendorWriteCapable hint="Будет отправлено только в отдельном START"
? "Инициировать приём данных и работу устройства" value={projectName}
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"} onChange={(event) => {
</Button> setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Сохранение имени само по себе не отправляет START на устройство."}
placeholder="Например, TEST001"
/>
<TextField
label="Адрес устройства"
hint="Тот же direct-LAN адрес, который вернул выбранный K1"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
/>
</>
) : null}
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
<Button
variant="primary"
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
onClick={() => void prepareProject()}
>
{pendingAction === "live"
? "Сохраняем проект и готовим приём…"
: "Сохранить проект и подготовить локальный приём"}
</Button>
) : null}
{controlPhase === "project-ready" && preparedAcquisition ? (
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !physicalAcceptanceConfirmed}
onClick={() => void startLive()}
>
{pendingAction === "live"
? "Отправляем один канонический START…"
: "Запустить сканирование K1"}
</Button>
) : null}
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void closeApplicationControlSession()}
>
Закрыть управляющую сессию без START
</Button>
) : null}
<p className="live-instruction"> <p className="live-instruction">
{!profileConfirmed {!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически." ? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady : controlPhase === "failed"
? vendorWriteCapable ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока." : controlPhase === "connecting"
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами." ? "Выполняются только операции 16 записанного диалога. Следующий этап начнётся только по вашей кнопке."
: "Сначала подключите устройство к WiFi или укажите локальный адрес."} : controlPhase === "workspace-requested"
? "Выполняется только операция входа в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
</p> </p>
</div> </div>
) : ( ) : (
@ -219,7 +312,22 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."} : "Активного источника сейчас нет."}
</p> </p>
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}> {control?.can_confirm_standby ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop(
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
)}
>
{pendingAction === "stop" {pendingAction === "stop"
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…" ? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"} : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}

View File

@ -1,7 +1,11 @@
import { Button } from "@nodedc/ui-react"; import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk"; import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type { AcquisitionState, XgridsAcquisition } from "../api"; import type {
AcquisitionState,
OperatorPresenceConfirmation,
XgridsAcquisition,
} from "../api";
import { import {
isSoftwareCommandedAcquisition, isSoftwareCommandedAcquisition,
shouldRenderSpatialControls, shouldRenderSpatialControls,
@ -19,6 +23,14 @@ interface PhasePresentation {
busy: boolean; busy: boolean;
} }
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
function phasePresentation( function phasePresentation(
acquisition: XgridsAcquisition, acquisition: XgridsAcquisition,
softwareCommanded: boolean, softwareCommanded: boolean,
@ -58,8 +70,10 @@ function phasePresentation(
busy: false, busy: false,
}, },
awaiting_external_stop: { awaiting_external_stop: {
label: "Ожидание остановки на устройстве", label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: "Mission Core ждёт подтверждения физической остановки K1.", detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true, busy: true,
}, },
stopping: { stopping: {
@ -94,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) { export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller(); const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller; const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
const acquisition = state?.acquisition; const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true; const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) { if (!acquisition || !shouldRenderSpatialControls(state)) {
@ -108,8 +122,14 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
acquisition.state, acquisition.state,
); );
const stopDisabled = pendingAction !== null || stopping; const stopDisabled = pendingAction !== null || stopping;
const controlFailure =
state.application_control_session?.state === "failed"
? state.application_control_session.failure?.message ||
"Канонический диалог остановлен; автоматический повтор запрещён."
: null;
const actionFailure = spatialActionFailure( const actionFailure = spatialActionFailure(
controller.error ?? controller.error ??
controlFailure ??
(cleanupPending (cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку." ? "Локальный поток или архив ещё не завершён. Повторите остановку."
: null), : null),
@ -149,7 +169,7 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
size="compact" size="compact"
variant="secondary" variant="secondary"
disabled={stopDisabled} disabled={stopDisabled}
onClick={() => void stop()} onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
> >
{pendingAction === "stop" {pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…" ? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
@ -159,6 +179,16 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку" ? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"} : softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button> </Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
</section> </section>
); );
} }

View File

@ -42,4 +42,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest, xgridsK1Manifest,
"application-control.shadow-disarm", "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",
),
}); });

View File

@ -140,13 +140,17 @@ export function XgridsK1RuntimeProvider({
if (!activeModel) return; if (!activeModel) return;
return registerDeactivation(async () => { return registerDeactivation(async () => {
if (controller.pendingAction !== null) return false; if (controller.pendingAction !== null) return false;
// Always ask the backend to stop. The browser snapshot may be stale or not // Never translate navigation/model switching into a K1 control command.
// loaded yet, while a previous local capture is still alive. // The operator must explicitly finish or close the canonical dialogue.
if (controller.state?.application_control_session?.control_socket_open) {
return false;
}
return controller.stop(); return controller.stop();
}); });
}, [ }, [
activeModel, activeModel,
controller.pendingAction, controller.pendingAction,
controller.state?.application_control_session?.control_socket_open,
controller.stop, controller.stop,
registerDeactivation, registerDeactivation,
]); ]);

View File

@ -8,6 +8,8 @@ import {
openEventSocket, openEventSocket,
type ConnectRequest, type ConnectRequest,
type EventSocketStatus, type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest, type PrepareAcquisitionRequest,
type ReplayRequest, type ReplayRequest,
type XgridsK1State, type XgridsK1State,
@ -25,6 +27,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction = export type PendingAction =
| "scan" | "scan"
| "connect" | "connect"
| "control"
| "live" | "live"
| "replay" | "replay"
| "stop" | "stop"
@ -167,7 +170,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state], [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) => (request: PrepareAcquisitionRequest) =>
run("live", async () => { run("live", async () => {
const plan = liveStartPlan(state); const plan = liveStartPlan(state);
@ -178,17 +200,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
} }
if (plan === "already-running" && state) return state; if (plan === "already-running" && state) return state;
const prepared = return (
plan === "resume-prepared" && state plan === "resume-prepared" && state
? state ? state
: await xgridsK1Api.prepareAcquisition(request); : await xgridsK1Api.prepareAcquisition(request)
const acquisition = prepared.acquisition; );
if (!acquisition?.acquisition_id) { }),
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма."); [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({ return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id, acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision, expected_state_revision: acquisition.state_revision,
physical_acceptance: physicalAcceptance,
}); });
}), }),
[run, state], [run, state],
@ -200,22 +231,57 @@ export function useXgridsK1Runtime(enabled: boolean) {
); );
const stop = useCallback( const stop = useCallback(
() => (physicalAcceptance?: OperatorPresenceConfirmation) =>
run("stop", () => { run("stop", () => {
const acquisition = state?.acquisition; const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state); const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) { if (acquisition && !acquisitionTerminal) {
const softwareCommanded = isSoftwareCommandedAcquisition(state);
if (softwareCommanded && !physicalAcceptance) {
throw new ApiError(
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
);
}
return xgridsK1Api.stopAcquisition({ return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id, 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(); return xgridsK1Api.stopSessionCompatibility();
}), }),
[run, state], [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 abort = useCallback(() => {
const acquisition = state?.acquisition; const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@ -348,9 +414,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null), clearError: () => setError(null),
scan, scan,
connect, connect,
prepareAndStartAcquisition, openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay, startReplay,
stop, stop,
confirmStoppedAtSteadyGreen,
abort, abort,
setObservationSourceActive, setObservationSourceActive,
updateViewerSettings, updateViewerSettings,

View File

@ -3,7 +3,7 @@
"kind": "DevicePlugin", "kind": "DevicePlugin",
"metadata": { "metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1", "id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.4.0", "version": "0.5.0",
"displayName": "XGRIDS K1 Integration" "displayName": "XGRIDS K1 Integration"
}, },
"spec": { "spec": {
@ -23,6 +23,7 @@
"device.discovery.ble", "device.discovery.ble",
"device.provisioning.wifi-over-ble", "device.provisioning.wifi-over-ble",
"network.mqtt.subscribe-private-lan", "network.mqtt.subscribe-private-lan",
"network.mqtt.publish-private-lan",
"network.rtsp.read-private-lan", "network.rtsp.read-private-lan",
"media.publish-local-browser", "media.publish-local-browser",
"evidence.write-session-artifacts", "evidence.write-session-artifacts",
@ -49,7 +50,10 @@
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] }, { "id": "viewer.settings.update", "mutating": true, "secretFields": [] },
{ "id": "application-control.shadow-state", "mutating": false, "secretFields": [] }, { "id": "application-control.shadow-state", "mutating": false, "secretFields": [] },
{ "id": "application-control.shadow-arm", "mutating": true, "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": [ "models": [
{ {

View File

@ -39,14 +39,13 @@ capture verifies the `ModelingRequest` topic, action values, field layout,
literal `{device_id}:ModelingRequest` session relation, retained start settings literal `{device_id}:ModelingRequest` session relation, retained start settings
and numeric success code. Retained client/wire evidence identifies OpenAPI as and numeric success code. Retained client/wire evidence identifies OpenAPI as
one private application-level value rather than a per-scanner profile. A fixed 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 read-only macOS Keychain loader, bounded shadow lease and dormant facade
orchestrator now exist. The installed runtime has no request-emission method or orchestrator remain. Plugin v0.5.0 separately installs an interactive acceptance
live MQTT sink. A separate uninstalled acceptance transport implements the transport implementing the retained MQTT session, exact response subscriptions
retained MQTT session, exact response subscriptions and poison-on-unknown and poison-on-unknown one-shot behavior. The descriptive profile still cannot
one-shot behavior. Operator-owned Keychain item provisioning, physical enable writes by itself: active control exists only while the operator-opened
acceptance, durable save completion and rollback evidence remain unresolved. canonical session owns the socket. Physical acceptance, durable native save
Acquisition therefore stays `operator-manual` through the verified physical completion and rollback evidence remain unresolved.
double-click.
The standalone encoder models the recovered wire schema, including enum values The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future outside the retained request. It is not an authorization policy: any future
publisher must enforce the exact profile mapping (`2/1/0`, omitted publisher must enforce the exact profile mapping (`2/1/0`, omitted

View File

@ -56,6 +56,10 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink, UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher, 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 import observe_modeling_report
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
LiveModelingControlSafety, LiveModelingControlSafety,
@ -85,7 +89,7 @@ from k1link.web.plugin_runtime import (
) )
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" 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_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" 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_STATE = "application-control.shadow-state"
ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm" ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm"
ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm" 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[ RequestedStreamId = Literal[
"spatial.point-cloud.live", "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) 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): class PrepareAcquisitionRequest(OperationContextRequest):
project_name: str = Field(min_length=1, max_length=96) project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15) host: str | None = Field(default=None, max_length=15)
@ -222,12 +240,14 @@ class PrepareAcquisitionRequest(OperationContextRequest):
class StartAcquisitionRequest(OperationContextRequest): class StartAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
expected_state_revision: int | None = Field(default=None, ge=1) expected_state_revision: int | None = Field(default=None, ge=1)
physical_acceptance: OperatorPresenceRequest | None = None
class StopAcquisitionRequest(OperationContextRequest): class StopAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
mode: Literal["graceful", "capture-only"] = "capture-only" mode: Literal["graceful", "capture-only"] = "capture-only"
operator_confirmed: bool = False operator_confirmed: bool = False
physical_acceptance: OperatorPresenceRequest | None = None
class AbortAcquisitionRequest(OperationContextRequest): 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: class XgridsK1CompatibilityService:
"""The proven K1 runtime kept intact behind the plugin facade.""" """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 # The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer. # explicitly. There is no implicit K1 decoder in the visual layer.
self._modeling_control_safety = LiveModelingControlSafety() self._modeling_control_safety = LiveModelingControlSafety()
authority_loader = (
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
)
self._application_control = DormantApplicationControlCoordinator( self._application_control = DormantApplicationControlCoordinator(
application_authority_loader or MacOSKeychainApplicationAuthorityLoader(), authority_loader,
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()), WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
) )
self._application_control_session = InteractiveApplicationControlSession(
authority_loader
)
self.runtime = VisualizationRuntime( self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message, normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message, message_observer=self._observe_runtime_message,
@ -329,6 +367,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access @_serialized_acquisition_access
def state(self) -> dict[str, Any]: def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict() application_control = self._application_control.snapshot().as_dict()
application_control_session = self._application_control_session.snapshot()
runtime = self.runtime.snapshot() runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot() camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview) self._reconcile_acquisition(runtime, camera_preview)
@ -389,6 +428,12 @@ class XgridsK1CompatibilityService:
active_profile_id = ( active_profile_id = (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None 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 { return {
"contract_version": "missioncore.device-plugin-state/v1alpha2", "contract_version": "missioncore.device-plugin-state/v1alpha2",
"phase": phase, "phase": phase,
@ -400,7 +445,11 @@ class XgridsK1CompatibilityService:
"profile_id": active_profile_id, "profile_id": active_profile_id,
"decision": "limited" if active_profile_id is not None else "unknown", "decision": "limited" if active_profile_id is not None else "unknown",
"permitted_mode": ( "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": ( "firmware_claim": (
"operator-attested-exact-3.0.2" "operator-attested-exact-3.0.2"
@ -408,7 +457,7 @@ class XgridsK1CompatibilityService:
else "exact-3.0.2-profile-not-attested" else "exact-3.0.2-profile-not-attested"
), ),
"attestation": compatibility_attestation, "attestation": compatibility_attestation,
"vendor_writes_enabled": False, "vendor_writes_enabled": active_control,
"camera_preview": ( "camera_preview": (
( (
"local-browser-adapter-available" "local-browser-adapter-available"
@ -421,6 +470,7 @@ class XgridsK1CompatibilityService:
}, },
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(), "modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
"application_control_execution": application_control, "application_control_execution": application_control,
"application_control_session": application_control_session,
"device_ref": ( "device_ref": (
{ {
"device_id": device_id, "device_id": device_id,
@ -521,6 +571,11 @@ class XgridsK1CompatibilityService:
return self.state() return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]: 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"]} known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids: if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth") raise ValueError("сначала найдите и выберите устройство через Bluetooth")
@ -714,6 +769,45 @@ class XgridsK1CompatibilityService:
} }
return self.state() 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 @_serialized_acquisition_access
def arm_application_control_shadow( def arm_application_control_shadow(
self, self,
@ -751,6 +845,30 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access @_serialized_acquisition_access
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
self._application_control.disarm() 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) requested_streams = _validated_requested_streams(request)
target = request.host or self.state()["k1_ip"] target = request.host or self.state()["k1_ip"]
if not isinstance(target, str) or not target: if not isinstance(target, str) or not target:
@ -762,6 +880,13 @@ class XgridsK1CompatibilityService:
raise ValueError( raise ValueError(
"адрес точки доступа устройства нельзя использовать как direct-LAN target" "адрес точки доступа устройства нельзя использовать как 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: with self._lock:
if self._acquisition_session_lease is not None: if self._acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана") raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
@ -824,7 +949,7 @@ class XgridsK1CompatibilityService:
device_id=device_id, device_id=device_id,
device_session_id=device_session_id, device_session_id=device_session_id,
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID, compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
control_mode="operator-manual", control_mode=control_mode,
requested_streams=requested_streams, requested_streams=requested_streams,
target_host=target, target_host=target,
duration_seconds=request.duration_seconds, duration_seconds=request.duration_seconds,
@ -846,6 +971,8 @@ class XgridsK1CompatibilityService:
self._compatibility_attestation = _attestation_snapshot( self._compatibility_attestation = _attestation_snapshot(
request.compatibility_attestation request.compatibility_attestation
) )
if control_mode == "plugin-commanded":
self._application_control_session.open_project_prompt()
self._operations.transition( self._operations.transition(
operation.operation_id, operation.operation_id,
"succeeded", "succeeded",
@ -867,6 +994,14 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access @_serialized_acquisition_access
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id) 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 ( if (
request.expected_state_revision is not None request.expected_state_revision is not None
and request.expected_state_revision != acquisition.state_revision and request.expected_state_revision != acquisition.state_revision
@ -877,6 +1012,11 @@ class XgridsK1CompatibilityService:
{ {
"acquisition_id": acquisition.acquisition_id, "acquisition_id": acquisition.acquisition_id,
"expected_state_revision": request.expected_state_revision, "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( operation, created = self._operations.begin(
@ -937,6 +1077,12 @@ class XgridsK1CompatibilityService:
project_name=project_name, project_name=project_name,
) )
self._arm_camera_recording(out_dir) 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: except Exception as exc:
if owns_start: if owns_start:
with self._lock: with self._lock:
@ -975,6 +1121,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access @_serialized_acquisition_access
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]: def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id) acquisition = self._require_acquisition(request.acquisition_id)
plugin_commanded = acquisition.control_mode == "plugin-commanded"
with self._lock: with self._lock:
acquisition_state = acquisition.state acquisition_state = acquisition.state
expected_stop_operation_id = self._acquisition_stop_operation_id expected_stop_operation_id = self._acquisition_stop_operation_id
@ -989,6 +1136,12 @@ class XgridsK1CompatibilityService:
raise ValueError( raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation" "подтверждение остановки должно ссылаться на исходную stop-operation"
) )
if plugin_commanded and not self._application_control_session.snapshot()[
"can_confirm_standby"
]:
raise RuntimeError(
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
)
elif ( elif (
request.mode == "graceful" request.mode == "graceful"
and acquisition_state and acquisition_state
@ -996,11 +1149,27 @@ class XgridsK1CompatibilityService:
"acquiring", "acquiring",
"awaiting_external_stop", "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) and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained)
): ):
raise ValueError( raise ValueError(
"graceful stop допустим только после подтверждённого потока point cloud" "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( request_fingerprint = self._request_fingerprint(
ACTION_ACQUISITION_STOP, ACTION_ACQUISITION_STOP,
@ -1091,12 +1260,29 @@ class XgridsK1CompatibilityService:
return self.state() return self.state()
if request.mode == "graceful" and not request.operator_confirmed: 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: with self._lock:
acquisition.transition( acquisition.transition(
"awaiting_external_stop", "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=( operator_instructions=(
"Дважды нажмите физическую кнопку устройства и подтвердите остановку.", (
"Канонический STOP запрошен один раз. Дождитесь READY, "
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
if plugin_commanded
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку."
)
),
), ),
) )
self._acquisition_stop_operation_id = operation.operation_id self._acquisition_stop_operation_id = operation.operation_id
@ -1104,7 +1290,11 @@ class XgridsK1CompatibilityService:
operation.operation_id, operation.operation_id,
"operator_action_required", "operator_action_required",
stage_code="awaiting-external-stop", 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}, result={"acquisition_id": acquisition.acquisition_id},
) )
return self.state() return self.state()
@ -1116,6 +1306,8 @@ class XgridsK1CompatibilityService:
message_code="acquisition.stop.stopping_receiver", message_code="acquisition.stop.stopping_receiver",
) )
try: try:
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
self._application_control_session.confirm_standby()
with self._lock: with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping") acquisition.transition("stopping", message_code="acquisition.stopping")
self._stop_acquisition_sources( self._stop_acquisition_sources(
@ -1190,6 +1382,11 @@ class XgridsK1CompatibilityService:
if not created: if not created:
return self.state() return self.state()
try: try:
if (
acquisition.control_mode == "plugin-commanded"
and acquisition.state == "prepared"
):
self._application_control_session.close_prestart()
with self._lock: with self._lock:
should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES
if should_abort: if should_abort:
@ -1338,6 +1535,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access @_serialized_acquisition_access
def close(self) -> None: def close(self) -> None:
self._application_control.close() self._application_control.close()
self._application_control_session.close()
with self._lock: with self._lock:
acquisition = self._acquisition acquisition = self._acquisition
close_active_acquisition = ( close_active_acquisition = (
@ -1613,6 +1811,7 @@ class XgridsK1CompatibilityService:
failed_stop_operation_id: str | None = None failed_stop_operation_id: str | None = None
unconfirmed_stop_operation_id: str | None = None unconfirmed_stop_operation_id: str | None = None
receiver_ready_operation_id: str | None = None receiver_ready_operation_id: str | None = None
receiver_plugin_commanded = False
acquisition_id: str | None = None acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None camera_failure_code: str | None = None
@ -1667,11 +1866,22 @@ class XgridsK1CompatibilityService:
completed_operation_id = self._acquisition_start_operation_id completed_operation_id = self._acquisition_start_operation_id
acquisition_id = acquisition.acquisition_id acquisition_id = acquisition.acquisition_id
elif acquisition.state == "starting" and source_ready: elif acquisition.state == "starting" and source_ready:
plugin_commanded = acquisition.control_mode == "plugin-commanded"
receiver_plugin_commanded = plugin_commanded
acquisition.transition( acquisition.transition(
"awaiting_external_start", "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=( operator_instructions=(
"Приёмник готов. Дважды нажмите физическую кнопку устройства.", (
"Приёмник готов. Ожидаем канонический START и калибровку K1; "
"не нажимайте физическую кнопку."
if plugin_commanded
else "Приёмник готов. Дважды нажмите физическую кнопку устройства."
),
), ),
) )
receiver_ready_operation_id = self._acquisition_start_operation_id receiver_ready_operation_id = self._acquisition_start_operation_id
@ -1776,9 +1986,17 @@ class XgridsK1CompatibilityService:
if receiver_ready_operation_id is not None: if receiver_ready_operation_id is not None:
self._operations.transition_if_pending( self._operations.transition_if_pending(
receiver_ready_operation_id, receiver_ready_operation_id,
"operator_action_required", "running" if receiver_plugin_commanded else "operator_action_required",
stage_code="awaiting-external-start", stage_code=(
message_code="acquisition.start.operator_action_required", "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}, result={"acquisition_id": acquisition_id},
) )
if completed_operation_id is not None: if completed_operation_id is not None:
@ -1856,6 +2074,18 @@ class XgridsK1ServicePort(Protocol):
def verify_connection(self) -> dict[str, Any]: ... 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( def arm_application_control_shadow(
self, self,
request: ShadowApplicationControlArmRequest, request: ShadowApplicationControlArmRequest,
@ -1917,6 +2147,9 @@ class XgridsK1PluginFacade:
ACTION_APPLICATION_CONTROL_SHADOW_STATE, ACTION_APPLICATION_CONTROL_SHADOW_STATE,
ACTION_APPLICATION_CONTROL_SHADOW_ARM, ACTION_APPLICATION_CONTROL_SHADOW_ARM,
ACTION_APPLICATION_CONTROL_SHADOW_DISARM, 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: if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM:
EmptyRequest.model_validate(payload) EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.disarm_application_control_shadow) 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: if action_id == ACTION_ACQUISITION_PREPARE:
prepare_request = PrepareAcquisitionRequest.model_validate(payload) prepare_request = PrepareAcquisitionRequest.model_validate(payload)
return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request) return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request)

View File

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

View File

@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source 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] repository_root = Path(__file__).resolve().parents[1]
connection_source = ( connection_source = (
repository_root repository_root
@ -125,8 +125,11 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
/ "K1AcquisitionPipeline.tsx" / "K1AcquisitionPipeline.tsx"
).read_text("utf-8") ).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
assert "Физическое состояние сканера остаётся неизвестным" in connection_source assert "Физическое состояние сканера остаётся неизвестным" in connection_source

View File

@ -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" item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
) )
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2" 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"]["hostApiRange"] == "v1alpha2"
assert plugin["spec"]["compatibilityProfiles"] == [ assert plugin["spec"]["compatibilityProfiles"] == [
{ {
@ -119,10 +119,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
"application-control.shadow-state", "application-control.shadow-state",
"application-control.shadow-arm", "application-control.shadow-arm",
"application-control.shadow-disarm", "application-control.shadow-disarm",
"application-control.session.open",
"application-control.workspace.enter",
"application-control.session.close",
} <= action_ids } <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == { assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1", "pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.4.0", "pluginVersion": "0.5.0",
"id": "xgrids.lixelkity-k1", "id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS", "vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1", "displayName": "XGRIDS LixelKity K1",

View File

@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
CameraPreviewSelectRequest, CameraPreviewSelectRequest,
CompatibilityAttestationRequest, CompatibilityAttestationRequest,
ConnectRequest, ConnectRequest,
OperatorPresenceRequest,
PrepareAcquisitionRequest, PrepareAcquisitionRequest,
ShadowApplicationControlArmRequest, ShadowApplicationControlArmRequest,
StartAcquisitionRequest, StartAcquisitionRequest,
@ -113,6 +114,61 @@ class FakeVisualizationRuntime:
self.stop() 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( def service_with_fake_runtime(
tmp_path: Path, tmp_path: Path,
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]: ) -> 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" 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: def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path) service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition( prepared = service.prepare_acquisition(

View File

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