feat(k1): wire canonical control session to UI

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | PAUSE (accepted START followed by `ALGORITHM_ERROR`) — first live attempt stopped before START on a batch-3 correlation failure; a second attempt correlated all 10 pre-START requests plus START but collapsed UI stages into 365 ms and omitted the retained post-START/control-session lifecycle. The collapsed path and standalone START/STOP are disabled. An uninstalled offline executor now uses explicit UI checkpoints plus live DeviceStatus gates; no further physical write is authorized yet |
| K1 application control | OPERATOR ACCEPTANCE READY — the failed collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner behind separate connection, workspace, project, START, STOP and steady-green UI actions. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the

View File

@ -260,7 +260,7 @@ operator power cycle restored steady green.
This disproves the earlier assumption that byte-matched pre-START payloads plus
a correlated START acknowledgement are sufficient acceptance. The legacy
collapsed `run_bootstrap()` executor now rejects before emission. Its
replacement is an uninstalled acceptance-only session owner. It requires the
replacement is an operator-driven acceptance session owner. It requires the
observed connection, workspace-entry and project-prompt checkpoints instead of
wall-clock floors, sends the immediate post-START ModelingStatus read, services
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
@ -270,9 +270,10 @@ standby. System-error and DeviceStatus reports are decoded into redacted safety
state; a fault forbids further automatic action. Standalone START/STOP are
disabled. MQTT keepalive/report processing also continues while the operator is
between UI actions; the 15120-second command permit is created at the actual
START or STOP confirmation, not at connection time. This path is
synthetic-test-only and remains outside facade/UI, so it
does not authorize a new physical attempt.
START or STOP confirmation, not at connection time. Plugin v0.5.0 now exposes
this path through separate plugin-owned UI actions. Repository tests use only
in-memory transports; physical execution occurs only from an explicit operator
click against an already selected direct-LAN K1.
The one-time Keychain administration command uses `security -w` as the final
argument so Apple's own TTY prompt receives the authority. Mission Core never

View File

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

View File

@ -138,21 +138,20 @@ after explicit operator attestation of firmware `3.0.2` and direct-LAN topology,
and records the basis as `operator-attested`. That claim is not device-derived
proof. The profile independently records observation, decode, replay,
physical-verification and write evidence. Loading it cannot authorize a
transport mutation. Application-command publishing remains disabled; the
separately reviewed BLE Wi-Fi provisioning write retains its own explicit
operator gate.
transport mutation. BLE Wi-Fi provisioning and application control retain
separate explicit operator gates.
An exact but inert K1 application-control substrate now lives inside the vendor
An exact K1 application-control substrate now lives inside the vendor
plugin. It byte-matches the retained ten-request pre-START dialogue plus
START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
device-status values into an observation-only state machine. The OpenAPI value
device-status values into a fail-closed state machine. The OpenAPI value
is one private application-level authority; vendor ID and serial come from the
BLE-selected live K1 and are cross-checked against status. No MQTT publisher
exists. A fixed Keychain loader, bounded lease and dormant facade orchestrator
are present, but there is no emission method or live MQTT sink. Keychain item
provisioning and stable-artifact proof after STOP remain unresolved. A separate
acceptance-only transport is not imported by the facade, so
`vendor_writes_enabled=false` remains authoritative.
BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
single interactive MQTT session from connection through STOP/standby, with one
explicit UI action per recorded lifecycle boundary and no automatic retry. A
fixed Keychain loader and dormant shadow coordinator remain present. Stable
physical proof after STOP remains unresolved; write capability is exposed only
while that interactive socket owner is active.
## Semantic lifecycle

View File

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

View File

@ -110,8 +110,8 @@ shadow arm/disarm/state actions and redacted snapshots. The coordinator has no
request-emission method and the installed sink is explicitly uninstalled; this
runtime wiring does not widen write authority.
`application_mqtt.py` and `application_acceptance.py` provide the next, still
uninstalled physical-acceptance boundary. The MQTT type repeats the retained
`application_mqtt.py` and `application_acceptance.py` provide the reviewed
physical-acceptance boundary. The MQTT type repeats the retained
3.1.1 session (`clean_session=false`, keepalive 60), subscribes only to exact
known response topics, admits only reviewed request topics and QoS2/retain-false
envelopes, and consumes explicit bootstrap ordinals/modeling actions before the
@ -134,8 +134,18 @@ MQTT through explicit STOP and waits for live READY plus operator-confirmed
standby. Fault/status observations are redacted and any fault forbids automatic
continuation. The socket remains serviced during operator/UI waits, and each
short action permit is created at its button confirmation rather than at
connection time. These types are synthetic-test-only, not imported by the facade,
declared as plugin actions or represented by UI controls.
connection time.
Plugin v0.5.0 installs this boundary through
`application_session.py`. One background owner is the only thread allowed to
touch the control MQTT client. REST/UI actions release exactly one checkpoint:
session open (16), workspace entry (7), project preparation (810), START, STOP
and steady-green confirmation. Preparing local reception happens before the
START event is released. After STOP, local capture remains owned until live
unbound READY and explicit visual confirmation. Page close, model switching,
polling and service state reads cannot emit START/STOP; model switching is
blocked while the socket is open. Repository tests replace the transport with
in-memory fakes and perform no equipment I/O.
`MacOSKeychainApplicationAuthorityProvisioner` invokes Apple's interactive
`security add-generic-password ... -w` prompt with `-w` last. The private value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,4 +42,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"application-control.shadow-disarm",
),
applicationControlSessionOpen: requirePluginAction(
xgridsK1Manifest,
"application-control.session.open",
),
applicationControlWorkspaceEnter: requirePluginAction(
xgridsK1Manifest,
"application-control.workspace.enter",
),
applicationControlSessionClose: requirePluginAction(
xgridsK1Manifest,
"application-control.session.close",
),
});

View File

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

View File

@ -8,6 +8,8 @@ import {
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
@ -25,6 +27,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "control"
| "live"
| "replay"
| "stop"
@ -167,7 +170,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state],
);
const prepareAndStartAcquisition = useCallback(
const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
[run],
);
const enterApplicationWorkspace = useCallback(
() =>
run("control", () =>
xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }),
),
[run],
);
const closeApplicationControlSession = useCallback(
() => run("control", () => xgridsK1Api.closeApplicationControlSession()),
[run],
);
const prepareAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
@ -178,17 +200,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
}
if (plan === "already-running" && state) return state;
const prepared =
return (
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
: await xgridsK1Api.prepareAcquisition(request)
);
}),
[run, state],
);
const startPreparedAcquisition = useCallback(
(physicalAcceptance: OperatorPresenceConfirmation) =>
run("live", async () => {
const acquisition = state?.acquisition;
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: physicalAcceptance,
});
}),
[run, state],
@ -200,22 +231,57 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
const stop = useCallback(
() =>
(physicalAcceptance?: OperatorPresenceConfirmation) =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
const softwareCommanded = isSoftwareCommandedAcquisition(state);
if (softwareCommanded && !physicalAcceptance) {
throw new ApiError(
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
);
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
mode: softwareCommanded ? "graceful" : "capture-only",
...(physicalAcceptance
? { physical_acceptance: physicalAcceptance }
: {}),
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state],
);
const confirmStoppedAtSteadyGreen = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
}
const stopOperation = [...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === "acquisition.stop" &&
operation.status === "operator_action_required",
);
if (!stopOperation) {
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "graceful",
operator_confirmed: true,
operation_id: stopOperation.operation_id,
});
}),
[run, state],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@ -348,9 +414,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null),
scan,
connect,
prepareAndStartAcquisition,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
setObservationSourceActive,
updateViewerSettings,

View File

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

View File

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

View File

@ -56,6 +56,10 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher,
)
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
InteractiveApplicationControlSession,
OperatorPresenceConfirmation,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
LiveModelingControlSafety,
@ -85,7 +89,7 @@ from k1link.web.plugin_runtime import (
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_PLUGIN_VERSION = "0.4.0"
XGRIDS_K1_PLUGIN_VERSION = "0.5.0"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
@ -137,6 +141,9 @@ ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
ACTION_APPLICATION_CONTROL_SHADOW_STATE = "application-control.shadow-state"
ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm"
ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm"
ACTION_APPLICATION_CONTROL_SESSION_OPEN = "application-control.session.open"
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER = "application-control.workspace.enter"
ACTION_APPLICATION_CONTROL_SESSION_CLOSE = "application-control.session.close"
RequestedStreamId = Literal[
"spatial.point-cloud.live",
@ -205,6 +212,17 @@ class OperationContextRequest(StrictRequest):
deadline_seconds: float | None = Field(default=None, ge=1.0, le=86_400.0)
class OperatorPresenceRequest(StrictRequest):
operator_present: Literal[True]
owner_controlled_device: Literal[True]
lixelgo_closed: Literal[True]
battery_storage_confirmed: Literal[True]
expected_physical_state_confirmed: Literal[True]
def confirmation(self) -> OperatorPresenceConfirmation:
return OperatorPresenceConfirmation(**self.model_dump())
class PrepareAcquisitionRequest(OperationContextRequest):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15)
@ -222,12 +240,14 @@ class PrepareAcquisitionRequest(OperationContextRequest):
class StartAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
expected_state_revision: int | None = Field(default=None, ge=1)
physical_acceptance: OperatorPresenceRequest | None = None
class StopAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
mode: Literal["graceful", "capture-only"] = "capture-only"
operator_confirmed: bool = False
physical_acceptance: OperatorPresenceRequest | None = None
class AbortAcquisitionRequest(OperationContextRequest):
@ -271,6 +291,18 @@ class ShadowApplicationControlArmRequest(StrictRequest):
)
class OpenApplicationControlSessionRequest(OperatorPresenceRequest):
timezone_name: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*$",
)
class EnterApplicationWorkspaceRequest(StrictRequest):
operator_confirmed: Literal[True]
class XgridsK1CompatibilityService:
"""The proven K1 runtime kept intact behind the plugin facade."""
@ -313,10 +345,16 @@ class XgridsK1CompatibilityService:
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self._modeling_control_safety = LiveModelingControlSafety()
authority_loader = (
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
)
self._application_control = DormantApplicationControlCoordinator(
application_authority_loader or MacOSKeychainApplicationAuthorityLoader(),
authority_loader,
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
)
self._application_control_session = InteractiveApplicationControlSession(
authority_loader
)
self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message,
@ -329,6 +367,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
application_control_session = self._application_control_session.snapshot()
runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview)
@ -389,6 +428,12 @@ class XgridsK1CompatibilityService:
active_profile_id = (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None
)
active_control = application_control_session["state"] not in {
"idle",
"completed",
"closed",
"failed",
}
return {
"contract_version": "missioncore.device-plugin-state/v1alpha2",
"phase": phase,
@ -400,7 +445,11 @@ class XgridsK1CompatibilityService:
"profile_id": active_profile_id,
"decision": "limited" if active_profile_id is not None else "unknown",
"permitted_mode": (
"read-only" if active_profile_id is not None else "evidence-only"
"active-control"
if active_profile_id is not None and active_control
else "read-only"
if active_profile_id is not None
else "evidence-only"
),
"firmware_claim": (
"operator-attested-exact-3.0.2"
@ -408,7 +457,7 @@ class XgridsK1CompatibilityService:
else "exact-3.0.2-profile-not-attested"
),
"attestation": compatibility_attestation,
"vendor_writes_enabled": False,
"vendor_writes_enabled": active_control,
"camera_preview": (
(
"local-browser-adapter-available"
@ -421,6 +470,7 @@ class XgridsK1CompatibilityService:
},
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
"application_control_execution": application_control,
"application_control_session": application_control_session,
"device_ref": (
{
"device_id": device_id,
@ -521,6 +571,11 @@ class XgridsK1CompatibilityService:
return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
control_state = self._application_control_session.snapshot()["state"]
if control_state not in {"idle", "completed", "closed"}:
raise RuntimeError(
"сначала завершите или безопасно закройте текущую control-сессию K1"
)
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
@ -714,6 +769,45 @@ class XgridsK1CompatibilityService:
}
return self.state()
@_serialized_acquisition_access
def open_application_control_session(
self,
request: OpenApplicationControlSessionRequest,
) -> dict[str, Any]:
"""Open one continuous canonical dialogue; no later stage is implied."""
with self._lock:
if self._provisioning_active:
raise RuntimeError("нельзя открывать control-сессию во время настройки Wi-Fi")
target = self._k1_ip
attestation = self._compatibility_attestation
acquisition = self._acquisition
if target is None or attestation is None:
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
self._application_control.disarm()
self._application_control_session.open(
host=validate_private_ipv4(target),
timezone_name=request.timezone_name,
confirmation=request.confirmation(),
)
return self.state()
@_serialized_acquisition_access
def enter_application_workspace(
self,
request: EnterApplicationWorkspaceRequest,
) -> dict[str, Any]:
del request
self._application_control_session.enter_workspace()
return self.state()
@_serialized_acquisition_access
def close_application_control_session(self) -> dict[str, Any]:
self._application_control_session.close_prestart()
return self.state()
@_serialized_acquisition_access
def arm_application_control_shadow(
self,
@ -751,6 +845,30 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
self._application_control.disarm()
control_state = str(self._application_control_session.snapshot()["state"])
if control_state == "failed":
raise RuntimeError(
"control-сессия K1 завершилась ошибкой и требует ручной проверки"
)
if control_state in {
"connecting",
"connection-ready",
"workspace-requested",
"project-requested",
"project-ready",
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
} and control_state != "workspace-ready":
raise RuntimeError(
"каноническая control-сессия должна ожидать сохранения проекта"
)
control_mode: Literal["operator-manual", "plugin-commanded"] = (
"plugin-commanded" if control_state == "workspace-ready" else "operator-manual"
)
requested_streams = _validated_requested_streams(request)
target = request.host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
@ -762,6 +880,13 @@ class XgridsK1CompatibilityService:
raise ValueError(
"адрес точки доступа устройства нельзя использовать как direct-LAN target"
)
if control_mode == "plugin-commanded":
with self._lock:
control_target = self._k1_ip
if control_target is None or target != validate_private_ipv4(control_target):
raise ValueError(
"локальный приём должен использовать тот же K1, что и control-сессия"
)
with self._lock:
if self._acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
@ -824,7 +949,7 @@ class XgridsK1CompatibilityService:
device_id=device_id,
device_session_id=device_session_id,
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
control_mode="operator-manual",
control_mode=control_mode,
requested_streams=requested_streams,
target_host=target,
duration_seconds=request.duration_seconds,
@ -846,6 +971,8 @@ class XgridsK1CompatibilityService:
self._compatibility_attestation = _attestation_snapshot(
request.compatibility_attestation
)
if control_mode == "plugin-commanded":
self._application_control_session.open_project_prompt()
self._operations.transition(
operation.operation_id,
"succeeded",
@ -867,6 +994,14 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id)
plugin_commanded = acquisition.control_mode == "plugin-commanded"
if plugin_commanded:
if request.physical_acceptance is None:
raise ValueError(
"START K1 требует явного подтверждения присутствия оператора"
)
if self._application_control_session.snapshot()["state"] != "project-ready":
raise RuntimeError("канонический диалог K1 ещё не готов принять START")
if (
request.expected_state_revision is not None
and request.expected_state_revision != acquisition.state_revision
@ -877,6 +1012,11 @@ class XgridsK1CompatibilityService:
{
"acquisition_id": acquisition.acquisition_id,
"expected_state_revision": request.expected_state_revision,
"physical_acceptance": (
request.physical_acceptance.model_dump(mode="json")
if request.physical_acceptance is not None
else None
),
},
)
operation, created = self._operations.begin(
@ -937,6 +1077,12 @@ class XgridsK1CompatibilityService:
project_name=project_name,
)
self._arm_camera_recording(out_dir)
if plugin_commanded:
assert request.physical_acceptance is not None
self._application_control_session.request_start(
project_name=project_name,
confirmation=request.physical_acceptance.confirmation(),
)
except Exception as exc:
if owns_start:
with self._lock:
@ -975,6 +1121,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id)
plugin_commanded = acquisition.control_mode == "plugin-commanded"
with self._lock:
acquisition_state = acquisition.state
expected_stop_operation_id = self._acquisition_stop_operation_id
@ -989,6 +1136,12 @@ class XgridsK1CompatibilityService:
raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation"
)
if plugin_commanded and not self._application_control_session.snapshot()[
"can_confirm_standby"
]:
raise RuntimeError(
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
)
elif (
request.mode == "graceful"
and acquisition_state
@ -996,11 +1149,27 @@ class XgridsK1CompatibilityService:
"acquiring",
"awaiting_external_stop",
}
and not (
plugin_commanded
and acquisition_state in {"starting", "awaiting_external_start"}
and self._application_control_session.snapshot()["state"] == "scanning"
)
and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained)
):
raise ValueError(
"graceful stop допустим только после подтверждённого потока point cloud"
)
if (
plugin_commanded
and request.mode == "graceful"
and not request.operator_confirmed
):
if request.physical_acceptance is None:
raise ValueError(
"STOP K1 требует явного подтверждения присутствия оператора"
)
if self._application_control_session.snapshot()["state"] != "scanning":
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
request_fingerprint = self._request_fingerprint(
ACTION_ACQUISITION_STOP,
@ -1091,12 +1260,29 @@ class XgridsK1CompatibilityService:
return self.state()
if request.mode == "graceful" and not request.operator_confirmed:
if plugin_commanded:
assert request.physical_acceptance is not None
self._application_control_session.request_stop(
confirmation=request.physical_acceptance.confirmation()
)
with self._lock:
acquisition.transition(
"awaiting_external_stop",
message_code="acquisition.stop.operator_action_required",
message_code=(
"acquisition.stop.device_stopping"
if plugin_commanded
else "acquisition.stop.operator_action_required"
),
operator_instructions=(
"Дважды нажмите физическую кнопку устройства и подтвердите остановку.",
(
"Канонический STOP запрошен один раз. Дождитесь READY, "
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
if plugin_commanded
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку."
)
),
),
)
self._acquisition_stop_operation_id = operation.operation_id
@ -1104,7 +1290,11 @@ class XgridsK1CompatibilityService:
operation.operation_id,
"operator_action_required",
stage_code="awaiting-external-stop",
message_code="acquisition.stop.operator_action_required",
message_code=(
"acquisition.stop.device_stopping"
if plugin_commanded
else "acquisition.stop.operator_action_required"
),
result={"acquisition_id": acquisition.acquisition_id},
)
return self.state()
@ -1116,6 +1306,8 @@ class XgridsK1CompatibilityService:
message_code="acquisition.stop.stopping_receiver",
)
try:
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
self._application_control_session.confirm_standby()
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self._stop_acquisition_sources(
@ -1190,6 +1382,11 @@ class XgridsK1CompatibilityService:
if not created:
return self.state()
try:
if (
acquisition.control_mode == "plugin-commanded"
and acquisition.state == "prepared"
):
self._application_control_session.close_prestart()
with self._lock:
should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES
if should_abort:
@ -1338,6 +1535,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def close(self) -> None:
self._application_control.close()
self._application_control_session.close()
with self._lock:
acquisition = self._acquisition
close_active_acquisition = (
@ -1613,6 +1811,7 @@ class XgridsK1CompatibilityService:
failed_stop_operation_id: str | None = None
unconfirmed_stop_operation_id: str | None = None
receiver_ready_operation_id: str | None = None
receiver_plugin_commanded = False
acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None
@ -1667,11 +1866,22 @@ class XgridsK1CompatibilityService:
completed_operation_id = self._acquisition_start_operation_id
acquisition_id = acquisition.acquisition_id
elif acquisition.state == "starting" and source_ready:
plugin_commanded = acquisition.control_mode == "plugin-commanded"
receiver_plugin_commanded = plugin_commanded
acquisition.transition(
"awaiting_external_start",
message_code="acquisition.start.operator_action_required",
message_code=(
"acquisition.start.device_initializing"
if plugin_commanded
else "acquisition.start.operator_action_required"
),
operator_instructions=(
"Приёмник готов. Дважды нажмите физическую кнопку устройства.",
(
"Приёмник готов. Ожидаем канонический START и калибровку K1; "
"не нажимайте физическую кнопку."
if plugin_commanded
else "Приёмник готов. Дважды нажмите физическую кнопку устройства."
),
),
)
receiver_ready_operation_id = self._acquisition_start_operation_id
@ -1776,9 +1986,17 @@ class XgridsK1CompatibilityService:
if receiver_ready_operation_id is not None:
self._operations.transition_if_pending(
receiver_ready_operation_id,
"operator_action_required",
stage_code="awaiting-external-start",
message_code="acquisition.start.operator_action_required",
"running" if receiver_plugin_commanded else "operator_action_required",
stage_code=(
"device-initializing"
if receiver_plugin_commanded
else "awaiting-external-start"
),
message_code=(
"acquisition.start.device_initializing"
if receiver_plugin_commanded
else "acquisition.start.operator_action_required"
),
result={"acquisition_id": acquisition_id},
)
if completed_operation_id is not None:
@ -1856,6 +2074,18 @@ class XgridsK1ServicePort(Protocol):
def verify_connection(self) -> dict[str, Any]: ...
def open_application_control_session(
self,
request: OpenApplicationControlSessionRequest,
) -> dict[str, Any]: ...
def enter_application_workspace(
self,
request: EnterApplicationWorkspaceRequest,
) -> dict[str, Any]: ...
def close_application_control_session(self) -> dict[str, Any]: ...
def arm_application_control_shadow(
self,
request: ShadowApplicationControlArmRequest,
@ -1917,6 +2147,9 @@ class XgridsK1PluginFacade:
ACTION_APPLICATION_CONTROL_SHADOW_STATE,
ACTION_APPLICATION_CONTROL_SHADOW_ARM,
ACTION_APPLICATION_CONTROL_SHADOW_DISARM,
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
}
)
@ -1975,6 +2208,23 @@ class XgridsK1PluginFacade:
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.disarm_application_control_shadow)
if action_id == ACTION_APPLICATION_CONTROL_SESSION_OPEN:
open_request = OpenApplicationControlSessionRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.open_application_control_session,
open_request,
)
if action_id == ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER:
workspace_request = EnterApplicationWorkspaceRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.enter_application_workspace,
workspace_request,
)
if action_id == ACTION_APPLICATION_CONTROL_SESSION_CLOSE:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.close_application_control_session
)
if action_id == ACTION_ACQUISITION_PREPARE:
prepare_request = PrepareAcquisitionRequest.model_validate(payload)
return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request)

View File

@ -0,0 +1,488 @@
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError,
PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
ShadowApplicationBootstrapOrchestrator,
build_canonical_post_start_observation,
)
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
ApplicationAuthorityLoader,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
ApplicationCommandOutcomeUnknown,
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
CommandHeaderIdentity,
ModelingAction,
MountType,
RecordMode,
ScanMode,
encode_modeling_start,
encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
ShadowModelingCommand,
)
ApplicationControlPhase = Literal[
"idle",
"connecting",
"connection-ready",
"workspace-requested",
"workspace-ready",
"project-requested",
"project-ready",
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
"completed",
"closed",
"failed",
]
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
@dataclass(frozen=True, slots=True)
class OperatorPresenceConfirmation:
operator_present: Literal[True]
owner_controlled_device: Literal[True]
lixelgo_closed: Literal[True]
battery_storage_confirmed: Literal[True]
expected_physical_state_confirmed: Literal[True]
def checklist(self, action: ModelingAction) -> PhysicalAcceptanceChecklist:
return PhysicalAcceptanceChecklist(
action=action,
operator_present=self.operator_present,
owner_controlled_device=self.owner_controlled_device,
lixelgo_closed=self.lixelgo_closed,
battery_storage_confirmed=self.battery_storage_confirmed,
expected_physical_state_confirmed=self.expected_physical_state_confirmed,
)
class InteractiveApplicationControlSession:
"""Own one canonical K1 MQTT dialogue across explicit operator UI events.
Only this background thread touches the MQTT client. UI requests merely
release one named checkpoint. No checkpoint is advanced by elapsed time,
and neither START nor STOP has an automatic retry path.
"""
def __init__(
self,
authority_loader: ApplicationAuthorityLoader,
*,
transport_factory: TransportFactory = ReviewedApplicationMqttTransport,
epoch_seconds: Callable[[], int] = lambda: int(time.time()),
) -> None:
self._authority_loader = authority_loader
self._transport_factory = transport_factory
self._epoch_seconds = epoch_seconds
self._lock = threading.RLock()
self._phase: ApplicationControlPhase = "idle"
self._host: str | None = None
self._timezone_name: str | None = None
self._thread: threading.Thread | None = None
self._transport: ReviewedApplicationMqttTransport | None = None
self._workspace_requested = threading.Event()
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_confirmation: OperatorPresenceConfirmation | None = None
self._stop_confirmation: OperatorPresenceConfirmation | None = None
self._project_name: str | None = None
self._failure: dict[str, object] | None = None
self._dialogue_snapshot: dict[str, object] | None = None
self._transport_snapshot: dict[str, object] | None = None
self._outcome_unknown = False
def open(
self,
*,
host: str,
timezone_name: str,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
# Validate every explicit confirmation before creating a network owner.
confirmation.checklist(ModelingAction.START)
with self._lock:
if self._phase in {"completed", "closed"} or (
self._phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
):
self._reset_locked()
if self._phase != "idle":
raise ApplicationAcceptanceError(
"application control session is already open or requires manual recovery"
)
self._host = host
self._timezone_name = timezone_name
self._set_phase_locked("connecting")
self._thread = threading.Thread(
target=self._run,
name="xgrids-k1-canonical-control",
daemon=True,
)
self._thread.start()
return self.snapshot()
def enter_workspace(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("connection-ready")
self._set_phase_locked("workspace-requested")
self._workspace_requested.set()
return self.snapshot()
def open_project_prompt(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("workspace-ready")
self._set_phase_locked("project-requested")
self._project_requested.set()
return self.snapshot()
def request_start(
self,
*,
project_name: str,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
with self._lock:
self._require_phase_locked("project-ready")
self._project_name = project_name
self._start_confirmation = confirmation
self._set_phase_locked("start-requested")
self._start_requested.set()
return self.snapshot()
def request_stop(
self,
*,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
with self._lock:
self._require_phase_locked("scanning")
self._stop_confirmation = confirmation
self._set_phase_locked("stop-requested")
self._stop_requested.set()
return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("awaiting-standby-confirmation")
if not self._device_ready_for_standby_confirmation_locked():
raise ApplicationAcceptanceError(
"K1 has not reported unbound READY after canonical STOP"
)
self._standby_confirmed.set()
return self.snapshot()
def close_prestart(self) -> dict[str, object]:
with self._lock:
if self._phase not in {
"connection-ready",
"workspace-ready",
"project-ready",
}:
raise ApplicationAcceptanceError(
"control session can be closed safely only between pre-START checkpoints"
)
self._cancel_requested = True
transport = self._transport
if transport is not None:
transport.close()
return self.snapshot()
def close(self) -> None:
"""Stop the process-owned socket without ever inventing a device STOP."""
with self._lock:
self._cancel_requested = True
transport = self._transport
thread = self._thread
if transport is not None:
transport.close()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
def snapshot(self) -> dict[str, object]:
with self._lock:
transport_snapshot = self._live_transport_snapshot_locked()
phase = self._phase
return {
"mode": "interactive-canonical",
"state": phase,
"control_socket_open": phase
not in {"idle", "completed", "closed", "failed"},
"can_open": phase in {"idle", "completed", "closed"}
or (
phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
),
"can_enter_workspace": phase == "connection-ready",
"can_prepare_project": phase == "workspace-ready",
"can_start": phase == "project-ready",
"can_stop": phase == "scanning",
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
"pending_operator_action": self._pending_operator_action_locked(),
"scripted_transitions": False,
"automatic_retry": False,
"outcome_unknown": self._outcome_unknown,
"failure": dict(self._failure) if self._failure is not None else None,
"dialogue": (
dict(self._dialogue_snapshot)
if self._dialogue_snapshot is not None
else None
),
"transport": transport_snapshot,
}
def _run(self) -> None:
executor: PhysicalAcceptanceDialogueExecutor | None = None
transport: ReviewedApplicationMqttTransport | None = None
try:
with self._lock:
host = self._host
timezone_name = self._timezone_name
if host is None or timezone_name is None:
raise ApplicationAcceptanceError("control session inputs are unavailable")
authority = self._authority_loader.load()
transport = self._transport_factory(host)
with self._lock:
self._transport = transport
transport.open()
orchestrator = ShadowApplicationBootstrapOrchestrator(
authority,
epoch_seconds=self._epoch_seconds(),
timezone_name=timezone_name,
)
executor = PhysicalAcceptanceDialogueExecutor(transport)
binding = executor.run_connection_stage(orchestrator)
self._set_phase("connection-ready")
workspace = executor.wait_for_operator_checkpoint(
"workspace-entered",
self._workspace_requested.is_set,
)
executor.run_workspace_entry_stage(orchestrator, workspace)
self._set_phase("workspace-ready")
project = executor.wait_for_operator_checkpoint(
"project-prompt-opened",
self._project_requested.is_set,
)
binding = executor.run_project_prompt_stage(orchestrator, project)
self._set_phase("project-ready")
start_checkpoint = executor.wait_for_operator_checkpoint(
"start-confirmed",
self._start_requested.is_set,
)
project_name, start_confirmation = self._start_request()
start_permit = PhysicalAcceptancePermit(
start_confirmation.checklist(ModelingAction.START)
)
self._set_phase("initializing")
executor.execute_canonical_start(
self._start_command(authority, binding, project_name),
build_canonical_post_start_observation(authority, binding),
authority=authority,
binding=binding,
permit=start_permit,
checkpoint=start_checkpoint,
)
self._set_phase("scanning")
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
stop_confirmation = self._stop_request()
stop_permit = PhysicalAcceptancePermit(
stop_confirmation.checklist(ModelingAction.STOP)
)
self._set_phase("stopping")
executor.execute_canonical_stop(
self._stop_command(authority, binding),
stop_permit,
)
self._set_phase("awaiting-standby-confirmation")
executor.maintain_post_stop_until_standby_confirmed(
self._standby_confirmed.is_set
)
self._set_phase("completed")
except Exception as exc:
with self._lock:
if self._cancel_requested:
self._set_phase_locked("closed")
else:
publish_attempts = 0
if transport is not None:
observed_attempts = transport.snapshot().as_dict().get(
"publish_attempts",
0,
)
if isinstance(observed_attempts, int) and not isinstance(
observed_attempts,
bool,
):
publish_attempts = observed_attempts
outcome_unknown = isinstance(
exc, ApplicationCommandOutcomeUnknown
) or self._phase in {
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
}
self._failure = {
"code": type(exc).__name__,
"message": str(exc),
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
}
self._outcome_unknown = outcome_unknown
self._set_phase_locked("failed")
finally:
if executor is not None:
with self._lock:
self._dialogue_snapshot = executor.snapshot()
if transport is not None:
try:
with self._lock:
self._transport_snapshot = transport.snapshot().as_dict()
finally:
transport.close()
with self._lock:
self._transport = None
@staticmethod
def _start_command(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
project_name: str,
) -> ShadowModelingCommand:
return ShadowModelingCommand.from_command(
encode_modeling_start(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=authority.openapi_key,
),
project_name=project_name,
record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
)
@staticmethod
def _stop_command(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> ShadowModelingCommand:
return ShadowModelingCommand.from_command(
encode_modeling_stop(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=authority.openapi_key,
)
)
)
def _start_request(self) -> tuple[str, OperatorPresenceConfirmation]:
with self._lock:
if self._project_name is None or self._start_confirmation is None:
raise ApplicationAcceptanceError("START request is incomplete")
return self._project_name, self._start_confirmation
def _stop_request(self) -> OperatorPresenceConfirmation:
with self._lock:
if self._stop_confirmation is None:
raise ApplicationAcceptanceError("STOP request is incomplete")
return self._stop_confirmation
def _set_phase(self, phase: ApplicationControlPhase) -> None:
with self._lock:
self._set_phase_locked(phase)
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
self._phase = phase
def _require_phase_locked(self, expected: ApplicationControlPhase) -> None:
if self._phase != expected:
raise ApplicationAcceptanceError(
f"control action requires {expected}; current state is {self._phase}"
)
def _device_ready_for_standby_confirmation_locked(self) -> bool:
if self._phase != "awaiting-standby-confirmation":
return False
snapshot = self._live_transport_snapshot_locked()
return (
snapshot.get("latest_device_session_state") == "ready"
and snapshot.get("latest_device_project_bound") is False
)
def _live_transport_snapshot_locked(self) -> dict[str, object]:
if self._transport is not None:
return self._transport.snapshot().as_dict()
return (
dict(self._transport_snapshot)
if self._transport_snapshot is not None
else {
"state": "not-opened",
"automatic_retry": False,
"automatic_reconnect": False,
}
)
def _pending_operator_action_locked(self) -> str | None:
return {
"connection-ready": "enter-workspace",
"workspace-ready": "prepare-project",
"project-ready": "start",
"scanning": "stop",
"awaiting-standby-confirmation": "confirm-steady-green",
}.get(self._phase)
def _reset_locked(self) -> None:
self._phase = "idle"
self._host = None
self._timezone_name = None
self._thread = None
self._transport = None
self._workspace_requested = threading.Event()
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_confirmation = None
self._stop_confirmation = None
self._project_name = None
self._failure = None
self._dialogue_snapshot = None
self._transport_snapshot = None
self._outcome_unknown = False

View File

@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None:
def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None:
repository_root = Path(__file__).resolve().parents[1]
connection_source = (
repository_root
@ -125,8 +125,11 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
/ "K1AcquisitionPipeline.tsx"
).read_text("utf-8")
assert "Подготовить локальный приём данных" in connection_source
assert "Программная команда запуска на K1 не отправляется" in connection_source
assert "Подключить управление K1" in connection_source
assert "Открыть рабочее пространство K1" in connection_source
assert "Сохранить проект и подготовить локальный приём" in connection_source
assert "Запустить сканирование K1" in connection_source
assert "никаких временных автопереходов и повторов нет" in connection_source
assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source

View File

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

View File

@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
CameraPreviewSelectRequest,
CompatibilityAttestationRequest,
ConnectRequest,
OperatorPresenceRequest,
PrepareAcquisitionRequest,
ShadowApplicationControlArmRequest,
StartAcquisitionRequest,
@ -113,6 +114,61 @@ class FakeVisualizationRuntime:
self.stop()
class FakeInteractiveControlSession:
def __init__(self) -> None:
self.state = "workspace-ready"
self.start_projects: list[str] = []
self.stop_calls = 0
self.confirm_calls = 0
def snapshot(self) -> dict[str, object]:
return {
"state": self.state,
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
}
def open_project_prompt(self) -> dict[str, object]:
assert self.state == "workspace-ready"
self.state = "project-ready"
return self.snapshot()
def request_start(self, *, project_name: str, confirmation: object) -> dict[str, object]:
assert confirmation is not None
assert self.state == "project-ready"
self.start_projects.append(project_name)
self.state = "scanning"
return self.snapshot()
def request_stop(self, *, confirmation: object) -> dict[str, object]:
assert confirmation is not None
assert self.state == "scanning"
self.stop_calls += 1
self.state = "awaiting-standby-confirmation"
return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
assert self.state == "awaiting-standby-confirmation"
self.confirm_calls += 1
self.state = "completed"
return self.snapshot()
def close_prestart(self) -> dict[str, object]:
self.state = "closed"
return self.snapshot()
def close(self) -> None:
self.state = "closed"
PHYSICAL_ACCEPTANCE = OperatorPresenceRequest(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
)
def service_with_fake_runtime(
tmp_path: Path,
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
@ -291,6 +347,68 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
control = FakeInteractiveControlSession()
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name="TEST001",
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
assert prepared["acquisition"]["control_mode"] == "plugin-commanded"
assert control.state == "project-ready"
assert runtime.start_calls == []
with pytest.raises(ValueError, match="подтверждения присутствия"):
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
assert control.start_projects == []
service.start_acquisition(
StartAcquisitionRequest(
acquisition_id=acquisition_id,
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
assert control.start_projects == ["TEST001"]
runtime.mark_ready()
runtime.pcl_frames = 1
acquiring = service.state()
assert acquiring["acquisition"]["state"] == "acquiring"
stopping = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
mode="graceful",
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
stop_operation = stopping["last_operation"]
assert control.stop_calls == 1
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
completed = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
mode="graceful",
operator_confirmed=True,
operation_id=stop_operation["operation_id"],
)
)
assert control.stop_calls == 1
assert control.confirm_calls == 1
assert completed["acquisition"]["state"] == "completed"
assert runtime.stop_calls == 1
def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(

View File

@ -0,0 +1,218 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Any
import pytest
from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
InteractiveApplicationControlSession,
OperatorPresenceConfirmation,
)
APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"
class FakeAuthorityLoader:
def __init__(self) -> None:
self.calls = 0
def load(self) -> ApplicationControlAuthority:
self.calls += 1
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
@dataclass
class FakeTransportSnapshot:
state: str
ready: bool
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"latest_device_session_state": "ready" if self.ready else "scanning",
"latest_device_project_bound": not self.ready,
"automatic_retry": False,
"automatic_reconnect": False,
}
class FakeTransport:
def __init__(self, host: str) -> None:
self.host = host
self.state = "new"
self.ready = True
def open(self) -> FakeTransportSnapshot:
self.state = "ready"
return self.snapshot()
def close(self) -> None:
self.state = "closed"
def snapshot(self) -> FakeTransportSnapshot:
return FakeTransportSnapshot(self.state, self.ready)
class FakeExecutor:
records: list[str] = []
def __init__(self, transport: FakeTransport) -> None:
self.transport = transport
self.binding = LiveDeviceControlBinding(
vendor_device_id="device-id",
device_serial="serial-id",
software_version="3.0.2",
system_version="3.0.2",
device_model="K1",
device_type="scanner",
is_activated=True,
)
def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
self.records.append("connection:1-6")
return self.binding
def wait_for_operator_checkpoint(
self,
event: str,
observed: Any,
) -> str:
self.records.append(f"wait:{event}")
deadline = time.monotonic() + 2.0
while not observed():
if time.monotonic() >= deadline:
raise TimeoutError(f"test did not release {event}")
threading.Event().wait(0.005)
return event
def run_workspace_entry_stage(
self,
_orchestrator: object,
_checkpoint: object,
) -> LiveDeviceControlBinding:
self.records.append("workspace:7")
return self.binding
def run_project_prompt_stage(
self,
_orchestrator: object,
_checkpoint: object,
) -> LiveDeviceControlBinding:
self.records.append("project:8-10")
return self.binding
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
self.records.append("start:11-14")
self.transport.ready = False
return object()
def maintain_active_until_stop_requested(self, observed: Any) -> None:
self.records.append("wait:stop")
while not observed():
threading.Event().wait(0.005)
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
self.records.append("stop")
self.transport.ready = True
return object()
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
self.records.append("wait:steady-green")
while not observed():
threading.Event().wait(0.005)
def snapshot(self) -> dict[str, object]:
return {"records": tuple(self.records), "automatic_retry": False}
def _confirmation() -> OperatorPresenceConfirmation:
return OperatorPresenceConfirmation(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
)
def _wait_phase(
session: InteractiveApplicationControlSession,
expected: str,
) -> dict[str, object]:
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
snapshot = session.snapshot()
if snapshot["state"] == expected:
return snapshot
threading.Event().wait(0.005)
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
def test_every_canonical_stage_requires_a_separate_operator_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
FakeExecutor.records = []
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
FakeExecutor,
)
loader = FakeAuthorityLoader()
transport = FakeTransport("192.168.1.20")
session = InteractiveApplicationControlSession(
loader,
transport_factory=lambda _host: transport, # type: ignore[arg-type]
epoch_seconds=lambda: 1_752_680_000,
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "connection-ready")
threading.Event().wait(0.02)
assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]
session.enter_workspace()
_wait_phase(session, "workspace-ready")
assert FakeExecutor.records[-1] == "wait:project-prompt-opened"
session.open_project_prompt()
_wait_phase(session, "project-ready")
assert FakeExecutor.records[-1] == "wait:start-confirmed"
session.request_start(project_name="TEST001", confirmation=_confirmation())
_wait_phase(session, "scanning")
assert FakeExecutor.records[-1] == "wait:stop"
session.request_stop(confirmation=_confirmation())
standby = _wait_phase(session, "awaiting-standby-confirmation")
assert standby["can_confirm_standby"] is True
assert FakeExecutor.records[-1] == "wait:steady-green"
session.confirm_standby()
completed = _wait_phase(session, "completed")
assert loader.calls == 1
assert completed["automatic_retry"] is False
assert completed["scripted_transitions"] is False
assert FakeExecutor.records == [
"connection:1-6",
"wait:workspace-entered",
"workspace:7",
"wait:project-prompt-opened",
"project:8-10",
"wait:start-confirmed",
"start:11-14",
"wait:stop",
"stop",
"wait:steady-green",
]