feat(k1): wire canonical control session to UI
This commit is contained in:
parent
36f0c93d2a
commit
7b7b5d6cad
22
README.md
22
README.md
|
|
@ -133,9 +133,9 @@ BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
|
||||||
reviewed-TSV replay, raw-first evidence storage, device-reported scan
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 15–120-second command permit is created at the actual
|
between UI actions; the 15–120-second command permit is created at the actual
|
||||||
START or STOP confirmation, not at connection time. This path is
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
1–6 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 8–10; 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 1–6 at connection, 7 at workspace entry, 8–10 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 (1–6), workspace entry (7), project preparation (8–10), START, STOP
|
||||||
|
and steady-green confirmation. Preparing local reception happens before the
|
||||||
|
START event is released. After STOP, local capture remains owned until live
|
||||||
|
unbound READY and explicit visual confirmation. Page close, model switching,
|
||||||
|
polling and service state reads cannot emit START/STOP; model switching is
|
||||||
|
blocked while the socket is open. Repository tests replace the transport with
|
||||||
|
in-memory fakes and perform no equipment I/O.
|
||||||
|
|
||||||
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
|
`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
|
||||||
|
|
|
||||||
|
|
@ -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 15–300 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 1–6. 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 8–10; 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 13–14.
|
||||||
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 1–6 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 runAutomaticSpatialSourceStart(
|
await prepareAcquisition({
|
||||||
() => prepareAndStartAcquisition({
|
|
||||||
project_name: projectNameValidation.value,
|
project_name: projectNameValidation.value,
|
||||||
...(targetHost ? { host: targetHost } : {}),
|
...(targetHost ? { host: targetHost } : {}),
|
||||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||||
}),
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLive = async () => {
|
||||||
|
await runAutomaticSpatialSourceStart(
|
||||||
|
() => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
|
||||||
activateAutomaticSpatialSource,
|
activateAutomaticSpatialSource,
|
||||||
openSpatialScene,
|
openSpatialScene,
|
||||||
);
|
);
|
||||||
|
|
@ -148,52 +179,114 @@ export function K1AcquisitionPipeline({
|
||||||
/>
|
/>
|
||||||
{effectiveSessionIntent === "live" ? (
|
{effectiveSessionIntent === "live" ? (
|
||||||
<div className="session-form">
|
<div className="session-form">
|
||||||
|
{(control?.can_open ?? controlPhase === "idle") ? (
|
||||||
|
<>
|
||||||
|
<Checker
|
||||||
|
checked={physicalAcceptanceConfirmed}
|
||||||
|
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||||
|
onChange={setPhysicalAcceptanceConfirmed}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon name="activity" />}
|
||||||
|
disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed}
|
||||||
|
onClick={() => void openControl()}
|
||||||
|
>
|
||||||
|
{pendingAction === "control"
|
||||||
|
? "Открываем канонический диалог…"
|
||||||
|
: "Подключить управление K1"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{controlPhase === "connection-ready" ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void enterApplicationWorkspace()}
|
||||||
|
>
|
||||||
|
{pendingAction === "control"
|
||||||
|
? "Открываем рабочее пространство…"
|
||||||
|
: "Открыть рабочее пространство K1"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? (
|
||||||
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Название проекта"
|
label="Название проекта"
|
||||||
hint="Обязательное поле · до 96 символов"
|
hint="Будет отправлено только в отдельном START"
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setProjectName(event.target.value);
|
setProjectName(event.target.value);
|
||||||
setProjectNameTouched(true);
|
setProjectNameTouched(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
|
||||||
disabled={sessionLocked}
|
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||||
description={projectNameTouched && projectNameValidation.error
|
description={projectNameTouched && projectNameValidation.error
|
||||||
? projectNameValidation.error
|
? projectNameValidation.error
|
||||||
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
|
: "Сохранение имени само по себе не отправляет START на устройство."}
|
||||||
placeholder="Например, Испытание маршрута 01"
|
placeholder="Например, TEST001"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Адрес устройства"
|
label="Адрес устройства"
|
||||||
hint="Обычно определяется автоматически"
|
hint="Тот же direct-LAN адрес, который вернул выбранный K1"
|
||||||
value={liveHost}
|
value={liveHost}
|
||||||
onChange={(event) => setLiveHost(event.target.value)}
|
onChange={(event) => setLiveHost(event.target.value)}
|
||||||
disabled={sessionLocked}
|
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
|
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
|
||||||
|
onClick={() => void prepareProject()}
|
||||||
|
>
|
||||||
|
{pendingAction === "live"
|
||||||
|
? "Сохраняем проект и готовим приём…"
|
||||||
|
: "Сохранить проект и подготовить локальный приём"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{controlPhase === "project-ready" && preparedAcquisition ? (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Icon name="activity" />}
|
icon={<Icon name="activity" />}
|
||||||
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
|
disabled={isBusy || !physicalAcceptanceConfirmed}
|
||||||
onClick={() => void submitLive()}
|
onClick={() => void startLive()}
|
||||||
>
|
>
|
||||||
{pendingAction === "live"
|
{pendingAction === "live"
|
||||||
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
|
? "Отправляем один канонический START…"
|
||||||
: vendorWriteCapable
|
: "Запустить сканирование K1"}
|
||||||
? "Инициировать приём данных и работу устройства"
|
|
||||||
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
|
|
||||||
</Button>
|
</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 не отправляется; поток подтверждается только реальными кадрами."
|
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
|
||||||
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
|
: 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 ? "Завершить подготовленный приём" : "Остановить локальный приём"}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||||
|
ApplicationAcceptanceError,
|
||||||
|
PhysicalAcceptanceChecklist,
|
||||||
|
PhysicalAcceptanceDialogueExecutor,
|
||||||
|
PhysicalAcceptancePermit,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
LiveDeviceControlBinding,
|
||||||
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
|
build_canonical_post_start_observation,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
|
||||||
|
ApplicationAuthorityLoader,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
|
ApplicationCommandOutcomeUnknown,
|
||||||
|
ReviewedApplicationMqttTransport,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
CommandHeaderIdentity,
|
||||||
|
ModelingAction,
|
||||||
|
MountType,
|
||||||
|
RecordMode,
|
||||||
|
ScanMode,
|
||||||
|
encode_modeling_start,
|
||||||
|
encode_modeling_stop,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
ShadowModelingCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
ApplicationControlPhase = Literal[
|
||||||
|
"idle",
|
||||||
|
"connecting",
|
||||||
|
"connection-ready",
|
||||||
|
"workspace-requested",
|
||||||
|
"workspace-ready",
|
||||||
|
"project-requested",
|
||||||
|
"project-ready",
|
||||||
|
"start-requested",
|
||||||
|
"initializing",
|
||||||
|
"scanning",
|
||||||
|
"stop-requested",
|
||||||
|
"stopping",
|
||||||
|
"awaiting-standby-confirmation",
|
||||||
|
"completed",
|
||||||
|
"closed",
|
||||||
|
"failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperatorPresenceConfirmation:
|
||||||
|
operator_present: Literal[True]
|
||||||
|
owner_controlled_device: Literal[True]
|
||||||
|
lixelgo_closed: Literal[True]
|
||||||
|
battery_storage_confirmed: Literal[True]
|
||||||
|
expected_physical_state_confirmed: Literal[True]
|
||||||
|
|
||||||
|
def checklist(self, action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||||
|
return PhysicalAcceptanceChecklist(
|
||||||
|
action=action,
|
||||||
|
operator_present=self.operator_present,
|
||||||
|
owner_controlled_device=self.owner_controlled_device,
|
||||||
|
lixelgo_closed=self.lixelgo_closed,
|
||||||
|
battery_storage_confirmed=self.battery_storage_confirmed,
|
||||||
|
expected_physical_state_confirmed=self.expected_physical_state_confirmed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveApplicationControlSession:
|
||||||
|
"""Own one canonical K1 MQTT dialogue across explicit operator UI events.
|
||||||
|
|
||||||
|
Only this background thread touches the MQTT client. UI requests merely
|
||||||
|
release one named checkpoint. No checkpoint is advanced by elapsed time,
|
||||||
|
and neither START nor STOP has an automatic retry path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
authority_loader: ApplicationAuthorityLoader,
|
||||||
|
*,
|
||||||
|
transport_factory: TransportFactory = ReviewedApplicationMqttTransport,
|
||||||
|
epoch_seconds: Callable[[], int] = lambda: int(time.time()),
|
||||||
|
) -> None:
|
||||||
|
self._authority_loader = authority_loader
|
||||||
|
self._transport_factory = transport_factory
|
||||||
|
self._epoch_seconds = epoch_seconds
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._phase: ApplicationControlPhase = "idle"
|
||||||
|
self._host: str | None = None
|
||||||
|
self._timezone_name: str | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._transport: ReviewedApplicationMqttTransport | None = None
|
||||||
|
self._workspace_requested = threading.Event()
|
||||||
|
self._project_requested = threading.Event()
|
||||||
|
self._start_requested = threading.Event()
|
||||||
|
self._stop_requested = threading.Event()
|
||||||
|
self._standby_confirmed = threading.Event()
|
||||||
|
self._cancel_requested = False
|
||||||
|
self._start_confirmation: OperatorPresenceConfirmation | None = None
|
||||||
|
self._stop_confirmation: OperatorPresenceConfirmation | None = None
|
||||||
|
self._project_name: str | None = None
|
||||||
|
self._failure: dict[str, object] | None = None
|
||||||
|
self._dialogue_snapshot: dict[str, object] | None = None
|
||||||
|
self._transport_snapshot: dict[str, object] | None = None
|
||||||
|
self._outcome_unknown = False
|
||||||
|
|
||||||
|
def open(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
timezone_name: str,
|
||||||
|
confirmation: OperatorPresenceConfirmation,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
# Validate every explicit confirmation before creating a network owner.
|
||||||
|
confirmation.checklist(ModelingAction.START)
|
||||||
|
with self._lock:
|
||||||
|
if self._phase in {"completed", "closed"} or (
|
||||||
|
self._phase == "failed"
|
||||||
|
and self._failure is not None
|
||||||
|
and self._failure.get("safe_to_retry") is True
|
||||||
|
):
|
||||||
|
self._reset_locked()
|
||||||
|
if self._phase != "idle":
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"application control session is already open or requires manual recovery"
|
||||||
|
)
|
||||||
|
self._host = host
|
||||||
|
self._timezone_name = timezone_name
|
||||||
|
self._set_phase_locked("connecting")
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="xgrids-k1-canonical-control",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def enter_workspace(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self._require_phase_locked("connection-ready")
|
||||||
|
self._set_phase_locked("workspace-requested")
|
||||||
|
self._workspace_requested.set()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def open_project_prompt(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self._require_phase_locked("workspace-ready")
|
||||||
|
self._set_phase_locked("project-requested")
|
||||||
|
self._project_requested.set()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def request_start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_name: str,
|
||||||
|
confirmation: OperatorPresenceConfirmation,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self._require_phase_locked("project-ready")
|
||||||
|
self._project_name = project_name
|
||||||
|
self._start_confirmation = confirmation
|
||||||
|
self._set_phase_locked("start-requested")
|
||||||
|
self._start_requested.set()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def request_stop(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
confirmation: OperatorPresenceConfirmation,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self._require_phase_locked("scanning")
|
||||||
|
self._stop_confirmation = confirmation
|
||||||
|
self._set_phase_locked("stop-requested")
|
||||||
|
self._stop_requested.set()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def confirm_standby(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
self._require_phase_locked("awaiting-standby-confirmation")
|
||||||
|
if not self._device_ready_for_standby_confirmation_locked():
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"K1 has not reported unbound READY after canonical STOP"
|
||||||
|
)
|
||||||
|
self._standby_confirmed.set()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def close_prestart(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
if self._phase not in {
|
||||||
|
"connection-ready",
|
||||||
|
"workspace-ready",
|
||||||
|
"project-ready",
|
||||||
|
}:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"control session can be closed safely only between pre-START checkpoints"
|
||||||
|
)
|
||||||
|
self._cancel_requested = True
|
||||||
|
transport = self._transport
|
||||||
|
if transport is not None:
|
||||||
|
transport.close()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Stop the process-owned socket without ever inventing a device STOP."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._cancel_requested = True
|
||||||
|
transport = self._transport
|
||||||
|
thread = self._thread
|
||||||
|
if transport is not None:
|
||||||
|
transport.close()
|
||||||
|
if thread is not None and thread is not threading.current_thread():
|
||||||
|
thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
with self._lock:
|
||||||
|
transport_snapshot = self._live_transport_snapshot_locked()
|
||||||
|
phase = self._phase
|
||||||
|
return {
|
||||||
|
"mode": "interactive-canonical",
|
||||||
|
"state": phase,
|
||||||
|
"control_socket_open": phase
|
||||||
|
not in {"idle", "completed", "closed", "failed"},
|
||||||
|
"can_open": phase in {"idle", "completed", "closed"}
|
||||||
|
or (
|
||||||
|
phase == "failed"
|
||||||
|
and self._failure is not None
|
||||||
|
and self._failure.get("safe_to_retry") is True
|
||||||
|
),
|
||||||
|
"can_enter_workspace": phase == "connection-ready",
|
||||||
|
"can_prepare_project": phase == "workspace-ready",
|
||||||
|
"can_start": phase == "project-ready",
|
||||||
|
"can_stop": phase == "scanning",
|
||||||
|
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
|
||||||
|
"pending_operator_action": self._pending_operator_action_locked(),
|
||||||
|
"scripted_transitions": False,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"outcome_unknown": self._outcome_unknown,
|
||||||
|
"failure": dict(self._failure) if self._failure is not None else None,
|
||||||
|
"dialogue": (
|
||||||
|
dict(self._dialogue_snapshot)
|
||||||
|
if self._dialogue_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"transport": transport_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||||
|
transport: ReviewedApplicationMqttTransport | None = None
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
host = self._host
|
||||||
|
timezone_name = self._timezone_name
|
||||||
|
if host is None or timezone_name is None:
|
||||||
|
raise ApplicationAcceptanceError("control session inputs are unavailable")
|
||||||
|
|
||||||
|
authority = self._authority_loader.load()
|
||||||
|
transport = self._transport_factory(host)
|
||||||
|
with self._lock:
|
||||||
|
self._transport = transport
|
||||||
|
transport.open()
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
authority,
|
||||||
|
epoch_seconds=self._epoch_seconds(),
|
||||||
|
timezone_name=timezone_name,
|
||||||
|
)
|
||||||
|
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
|
binding = executor.run_connection_stage(orchestrator)
|
||||||
|
self._set_phase("connection-ready")
|
||||||
|
|
||||||
|
workspace = executor.wait_for_operator_checkpoint(
|
||||||
|
"workspace-entered",
|
||||||
|
self._workspace_requested.is_set,
|
||||||
|
)
|
||||||
|
executor.run_workspace_entry_stage(orchestrator, workspace)
|
||||||
|
self._set_phase("workspace-ready")
|
||||||
|
|
||||||
|
project = executor.wait_for_operator_checkpoint(
|
||||||
|
"project-prompt-opened",
|
||||||
|
self._project_requested.is_set,
|
||||||
|
)
|
||||||
|
binding = executor.run_project_prompt_stage(orchestrator, project)
|
||||||
|
self._set_phase("project-ready")
|
||||||
|
|
||||||
|
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"start-confirmed",
|
||||||
|
self._start_requested.is_set,
|
||||||
|
)
|
||||||
|
project_name, start_confirmation = self._start_request()
|
||||||
|
start_permit = PhysicalAcceptancePermit(
|
||||||
|
start_confirmation.checklist(ModelingAction.START)
|
||||||
|
)
|
||||||
|
self._set_phase("initializing")
|
||||||
|
executor.execute_canonical_start(
|
||||||
|
self._start_command(authority, binding, project_name),
|
||||||
|
build_canonical_post_start_observation(authority, binding),
|
||||||
|
authority=authority,
|
||||||
|
binding=binding,
|
||||||
|
permit=start_permit,
|
||||||
|
checkpoint=start_checkpoint,
|
||||||
|
)
|
||||||
|
self._set_phase("scanning")
|
||||||
|
|
||||||
|
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||||
|
stop_confirmation = self._stop_request()
|
||||||
|
stop_permit = PhysicalAcceptancePermit(
|
||||||
|
stop_confirmation.checklist(ModelingAction.STOP)
|
||||||
|
)
|
||||||
|
self._set_phase("stopping")
|
||||||
|
executor.execute_canonical_stop(
|
||||||
|
self._stop_command(authority, binding),
|
||||||
|
stop_permit,
|
||||||
|
)
|
||||||
|
self._set_phase("awaiting-standby-confirmation")
|
||||||
|
executor.maintain_post_stop_until_standby_confirmed(
|
||||||
|
self._standby_confirmed.is_set
|
||||||
|
)
|
||||||
|
self._set_phase("completed")
|
||||||
|
except Exception as exc:
|
||||||
|
with self._lock:
|
||||||
|
if self._cancel_requested:
|
||||||
|
self._set_phase_locked("closed")
|
||||||
|
else:
|
||||||
|
publish_attempts = 0
|
||||||
|
if transport is not None:
|
||||||
|
observed_attempts = transport.snapshot().as_dict().get(
|
||||||
|
"publish_attempts",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if isinstance(observed_attempts, int) and not isinstance(
|
||||||
|
observed_attempts,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
publish_attempts = observed_attempts
|
||||||
|
outcome_unknown = isinstance(
|
||||||
|
exc, ApplicationCommandOutcomeUnknown
|
||||||
|
) or self._phase in {
|
||||||
|
"start-requested",
|
||||||
|
"initializing",
|
||||||
|
"scanning",
|
||||||
|
"stop-requested",
|
||||||
|
"stopping",
|
||||||
|
"awaiting-standby-confirmation",
|
||||||
|
}
|
||||||
|
self._failure = {
|
||||||
|
"code": type(exc).__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
|
||||||
|
}
|
||||||
|
self._outcome_unknown = outcome_unknown
|
||||||
|
self._set_phase_locked("failed")
|
||||||
|
finally:
|
||||||
|
if executor is not None:
|
||||||
|
with self._lock:
|
||||||
|
self._dialogue_snapshot = executor.snapshot()
|
||||||
|
if transport is not None:
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
self._transport_snapshot = transport.snapshot().as_dict()
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
with self._lock:
|
||||||
|
self._transport = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _start_command(
|
||||||
|
authority: ApplicationControlAuthority,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
project_name: str,
|
||||||
|
) -> ShadowModelingCommand:
|
||||||
|
return ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_start(
|
||||||
|
CommandHeaderIdentity(
|
||||||
|
device_id=binding.vendor_device_id,
|
||||||
|
openapi_key=authority.openapi_key,
|
||||||
|
),
|
||||||
|
project_name=project_name,
|
||||||
|
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||||
|
scan_mode=ScanMode.LCC,
|
||||||
|
mount_type=MountType.HANDHELD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stop_command(
|
||||||
|
authority: ApplicationControlAuthority,
|
||||||
|
binding: LiveDeviceControlBinding,
|
||||||
|
) -> ShadowModelingCommand:
|
||||||
|
return ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_stop(
|
||||||
|
CommandHeaderIdentity(
|
||||||
|
device_id=binding.vendor_device_id,
|
||||||
|
openapi_key=authority.openapi_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_request(self) -> tuple[str, OperatorPresenceConfirmation]:
|
||||||
|
with self._lock:
|
||||||
|
if self._project_name is None or self._start_confirmation is None:
|
||||||
|
raise ApplicationAcceptanceError("START request is incomplete")
|
||||||
|
return self._project_name, self._start_confirmation
|
||||||
|
|
||||||
|
def _stop_request(self) -> OperatorPresenceConfirmation:
|
||||||
|
with self._lock:
|
||||||
|
if self._stop_confirmation is None:
|
||||||
|
raise ApplicationAcceptanceError("STOP request is incomplete")
|
||||||
|
return self._stop_confirmation
|
||||||
|
|
||||||
|
def _set_phase(self, phase: ApplicationControlPhase) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._set_phase_locked(phase)
|
||||||
|
|
||||||
|
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
||||||
|
self._phase = phase
|
||||||
|
|
||||||
|
def _require_phase_locked(self, expected: ApplicationControlPhase) -> None:
|
||||||
|
if self._phase != expected:
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
f"control action requires {expected}; current state is {self._phase}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _device_ready_for_standby_confirmation_locked(self) -> bool:
|
||||||
|
if self._phase != "awaiting-standby-confirmation":
|
||||||
|
return False
|
||||||
|
snapshot = self._live_transport_snapshot_locked()
|
||||||
|
return (
|
||||||
|
snapshot.get("latest_device_session_state") == "ready"
|
||||||
|
and snapshot.get("latest_device_project_bound") is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _live_transport_snapshot_locked(self) -> dict[str, object]:
|
||||||
|
if self._transport is not None:
|
||||||
|
return self._transport.snapshot().as_dict()
|
||||||
|
return (
|
||||||
|
dict(self._transport_snapshot)
|
||||||
|
if self._transport_snapshot is not None
|
||||||
|
else {
|
||||||
|
"state": "not-opened",
|
||||||
|
"automatic_retry": False,
|
||||||
|
"automatic_reconnect": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pending_operator_action_locked(self) -> str | None:
|
||||||
|
return {
|
||||||
|
"connection-ready": "enter-workspace",
|
||||||
|
"workspace-ready": "prepare-project",
|
||||||
|
"project-ready": "start",
|
||||||
|
"scanning": "stop",
|
||||||
|
"awaiting-standby-confirmation": "confirm-steady-green",
|
||||||
|
}.get(self._phase)
|
||||||
|
|
||||||
|
def _reset_locked(self) -> None:
|
||||||
|
self._phase = "idle"
|
||||||
|
self._host = None
|
||||||
|
self._timezone_name = None
|
||||||
|
self._thread = None
|
||||||
|
self._transport = None
|
||||||
|
self._workspace_requested = threading.Event()
|
||||||
|
self._project_requested = threading.Event()
|
||||||
|
self._start_requested = threading.Event()
|
||||||
|
self._stop_requested = threading.Event()
|
||||||
|
self._standby_confirmed = threading.Event()
|
||||||
|
self._cancel_requested = False
|
||||||
|
self._start_confirmation = None
|
||||||
|
self._stop_confirmation = None
|
||||||
|
self._project_name = None
|
||||||
|
self._failure = None
|
||||||
|
self._dialogue_snapshot = None
|
||||||
|
self._transport_snapshot = None
|
||||||
|
self._outcome_unknown = False
|
||||||
|
|
@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
|
||||||
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
LiveDeviceControlBinding,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
|
||||||
|
InteractiveApplicationControlSession,
|
||||||
|
OperatorPresenceConfirmation,
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAuthorityLoader:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def load(self) -> ApplicationControlAuthority:
|
||||||
|
self.calls += 1
|
||||||
|
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeTransportSnapshot:
|
||||||
|
state: str
|
||||||
|
ready: bool
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||||
|
"latest_device_project_bound": not self.ready,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"automatic_reconnect": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self, host: str) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.state = "new"
|
||||||
|
self.ready = True
|
||||||
|
|
||||||
|
def open(self) -> FakeTransportSnapshot:
|
||||||
|
self.state = "ready"
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.state = "closed"
|
||||||
|
|
||||||
|
def snapshot(self) -> FakeTransportSnapshot:
|
||||||
|
return FakeTransportSnapshot(self.state, self.ready)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExecutor:
|
||||||
|
records: list[str] = []
|
||||||
|
|
||||||
|
def __init__(self, transport: FakeTransport) -> None:
|
||||||
|
self.transport = transport
|
||||||
|
self.binding = LiveDeviceControlBinding(
|
||||||
|
vendor_device_id="device-id",
|
||||||
|
device_serial="serial-id",
|
||||||
|
software_version="3.0.2",
|
||||||
|
system_version="3.0.2",
|
||||||
|
device_model="K1",
|
||||||
|
device_type="scanner",
|
||||||
|
is_activated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
|
||||||
|
self.records.append("connection:1-6")
|
||||||
|
return self.binding
|
||||||
|
|
||||||
|
def wait_for_operator_checkpoint(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
observed: Any,
|
||||||
|
) -> str:
|
||||||
|
self.records.append(f"wait:{event}")
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while not observed():
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise TimeoutError(f"test did not release {event}")
|
||||||
|
threading.Event().wait(0.005)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def run_workspace_entry_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
_checkpoint: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
self.records.append("workspace:7")
|
||||||
|
return self.binding
|
||||||
|
|
||||||
|
def run_project_prompt_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
_checkpoint: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
self.records.append("project:8-10")
|
||||||
|
return self.binding
|
||||||
|
|
||||||
|
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||||
|
self.records.append("start:11-14")
|
||||||
|
self.transport.ready = False
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def maintain_active_until_stop_requested(self, observed: Any) -> None:
|
||||||
|
self.records.append("wait:stop")
|
||||||
|
while not observed():
|
||||||
|
threading.Event().wait(0.005)
|
||||||
|
|
||||||
|
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
|
||||||
|
self.records.append("stop")
|
||||||
|
self.transport.ready = True
|
||||||
|
return object()
|
||||||
|
|
||||||
|
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||||
|
self.records.append("wait:steady-green")
|
||||||
|
while not observed():
|
||||||
|
threading.Event().wait(0.005)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {"records": tuple(self.records), "automatic_retry": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmation() -> OperatorPresenceConfirmation:
|
||||||
|
return OperatorPresenceConfirmation(
|
||||||
|
operator_present=True,
|
||||||
|
owner_controlled_device=True,
|
||||||
|
lixelgo_closed=True,
|
||||||
|
battery_storage_confirmed=True,
|
||||||
|
expected_physical_state_confirmed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_phase(
|
||||||
|
session: InteractiveApplicationControlSession,
|
||||||
|
expected: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
snapshot = session.snapshot()
|
||||||
|
if snapshot["state"] == expected:
|
||||||
|
return snapshot
|
||||||
|
threading.Event().wait(0.005)
|
||||||
|
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
FakeExecutor.records = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
FakeExecutor,
|
||||||
|
)
|
||||||
|
loader = FakeAuthorityLoader()
|
||||||
|
transport = FakeTransport("192.168.1.20")
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
loader,
|
||||||
|
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||||
|
epoch_seconds=lambda: 1_752_680_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
_wait_phase(session, "connection-ready")
|
||||||
|
threading.Event().wait(0.02)
|
||||||
|
assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]
|
||||||
|
|
||||||
|
session.enter_workspace()
|
||||||
|
_wait_phase(session, "workspace-ready")
|
||||||
|
assert FakeExecutor.records[-1] == "wait:project-prompt-opened"
|
||||||
|
|
||||||
|
session.open_project_prompt()
|
||||||
|
_wait_phase(session, "project-ready")
|
||||||
|
assert FakeExecutor.records[-1] == "wait:start-confirmed"
|
||||||
|
|
||||||
|
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||||
|
_wait_phase(session, "scanning")
|
||||||
|
assert FakeExecutor.records[-1] == "wait:stop"
|
||||||
|
|
||||||
|
session.request_stop(confirmation=_confirmation())
|
||||||
|
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||||
|
assert standby["can_confirm_standby"] is True
|
||||||
|
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||||
|
|
||||||
|
session.confirm_standby()
|
||||||
|
completed = _wait_phase(session, "completed")
|
||||||
|
assert loader.calls == 1
|
||||||
|
assert completed["automatic_retry"] is False
|
||||||
|
assert completed["scripted_transitions"] is False
|
||||||
|
assert FakeExecutor.records == [
|
||||||
|
"connection:1-6",
|
||||||
|
"wait:workspace-entered",
|
||||||
|
"workspace:7",
|
||||||
|
"wait:project-prompt-opened",
|
||||||
|
"project:8-10",
|
||||||
|
"wait:start-confirmed",
|
||||||
|
"start:11-14",
|
||||||
|
"wait:stop",
|
||||||
|
"stop",
|
||||||
|
"wait:steady-green",
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue