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
+22 -19
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
+6 -4
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.
+77
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";
@@ -14,6 +14,7 @@ import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
isConfirmedLiveState,
isSoftwareCommandedAcquisition,
isSourceRuntimeBusy,
isVendorWriteCapable,
recoverableAcquisition,
@@ -21,6 +22,7 @@ import {
} from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
import type { OperatorPresenceConfirmation } from "../api";
type SessionIntent = "live" | "replay";
@@ -29,6 +31,14 @@ const sessionItems = [
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
export function K1AcquisitionPipeline({
controller,
profileConfirmed,
@@ -43,9 +53,14 @@ export function K1AcquisitionPipeline({
const {
state,
pendingAction,
prepareAndStartAcquisition,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@@ -55,12 +70,15 @@ export function K1AcquisitionPipeline({
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName);
const vendorWriteCapable = isVendorWriteCapable(state);
const control = state?.application_control_session;
const controlPhase = control?.state ?? "idle";
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -103,16 +121,29 @@ export function K1AcquisitionPipeline({
[sessionLocked],
);
const submitLive = async () => {
const openControl = async () => {
if (!physicalAcceptanceConfirmed || !state?.k1_ip) return;
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
await openApplicationControlSession({
...PHYSICAL_ACCEPTANCE,
timezone_name: timezoneName,
});
};
const prepareProject = async () => {
setProjectNameTouched(true);
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim();
await prepareAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
};
const startLive = async () => {
await runAutomaticSpatialSourceStart(
() => prepareAndStartAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
}),
() => startPreparedAcquisition(PHYSICAL_ACCEPTANCE),
activateAutomaticSpatialSource,
openSpatialScene,
);
@@ -148,52 +179,114 @@ export function K1AcquisitionPipeline({
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Название проекта"
hint="Обязательное поле · до 96 символов"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={sessionLocked}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
placeholder="Например, Испытание маршрута 01"
/>
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
disabled={sessionLocked}
spellCheck={false}
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
onClick={() => void submitLive()}
>
{pendingAction === "live"
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
: vendorWriteCapable
? "Инициировать приём данных и работу устройства"
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
</Button>
{(control?.can_open ?? controlPhase === "idle") ? (
<>
<Checker
checked={physicalAcceptanceConfirmed}
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
onChange={setPhysicalAcceptanceConfirmed}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !state?.k1_ip || !physicalAcceptanceConfirmed}
onClick={() => void openControl()}
>
{pendingAction === "control"
? "Открываем канонический диалог…"
: "Подключить управление K1"}
</Button>
</>
) : null}
{controlPhase === "connection-ready" ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void enterApplicationWorkspace()}
>
{pendingAction === "control"
? "Открываем рабочее пространство…"
: "Открыть рабочее пространство K1"}
</Button>
) : null}
{["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? (
<>
<TextField
label="Название проекта"
hint="Будет отправлено только в отдельном START"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Сохранение имени само по себе не отправляет START на устройство."}
placeholder="Например, TEST001"
/>
<TextField
label="Адрес устройства"
hint="Тот же direct-LAN адрес, который вернул выбранный K1"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
</>
) : null}
{controlPhase === "workspace-ready" && !preparedAcquisition ? (
<Button
variant="primary"
disabled={isBusy || projectNameValidation.error !== null || !liveTargetReady}
onClick={() => void prepareProject()}
>
{pendingAction === "live"
? "Сохраняем проект и готовим приём…"
: "Сохранить проект и подготовить локальный приём"}
</Button>
) : null}
{controlPhase === "project-ready" && preparedAcquisition ? (
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !physicalAcceptanceConfirmed}
onClick={() => void startLive()}
>
{pendingAction === "live"
? "Отправляем один канонический START…"
: "Запустить сканирование K1"}
</Button>
) : null}
{["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void closeApplicationControlSession()}
>
Закрыть управляющую сессию без START
</Button>
) : null}
<p className="live-instruction">
{!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? vendorWriteCapable
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
: controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
: controlPhase === "connecting"
? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке."
: controlPhase === "workspace-requested"
? "Выполняется только операция входа в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Каждый этап канонического диалога запускается отдельным действием оператора."}
</p>
</div>
) : (
@@ -219,7 +312,22 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>
{control?.can_confirm_standby ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop(
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
)}
>
{pendingAction === "stop"
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
@@ -1,7 +1,11 @@
import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type { AcquisitionState, XgridsAcquisition } from "../api";
import type {
AcquisitionState,
OperatorPresenceConfirmation,
XgridsAcquisition,
} from "../api";
import {
isSoftwareCommandedAcquisition,
shouldRenderSpatialControls,
@@ -19,6 +23,14 @@ interface PhasePresentation {
busy: boolean;
}
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
function phasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
@@ -58,8 +70,10 @@ function phasePresentation(
busy: false,
},
awaiting_external_stop: {
label: "Ожидание остановки на устройстве",
detail: "Mission Core ждёт подтверждения физической остановки K1.",
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
@@ -94,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller;
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
@@ -108,8 +122,14 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
acquisition.state,
);
const stopDisabled = pendingAction !== null || stopping;
const controlFailure =
state.application_control_session?.state === "failed"
? state.application_control_session.failure?.message ||
"Канонический диалог остановлен; автоматический повтор запрещён."
: null;
const actionFailure = spatialActionFailure(
controller.error ??
controlFailure ??
(cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку."
: null),
@@ -149,7 +169,7 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop()}
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
@@ -159,6 +179,16 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
</section>
);
}
@@ -42,4 +42,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"application-control.shadow-disarm",
),
applicationControlSessionOpen: requirePluginAction(
xgridsK1Manifest,
"application-control.session.open",
),
applicationControlWorkspaceEnter: requirePluginAction(
xgridsK1Manifest,
"application-control.workspace.enter",
),
applicationControlSessionClose: requirePluginAction(
xgridsK1Manifest,
"application-control.session.close",
),
});
@@ -140,13 +140,17 @@ export function XgridsK1RuntimeProvider({
if (!activeModel) return;
return registerDeactivation(async () => {
if (controller.pendingAction !== null) return false;
// Always ask the backend to stop. The browser snapshot may be stale or not
// loaded yet, while a previous local capture is still alive.
// Never translate navigation/model switching into a K1 control command.
// The operator must explicitly finish or close the canonical dialogue.
if (controller.state?.application_control_session?.control_socket_open) {
return false;
}
return controller.stop();
});
}, [
activeModel,
controller.pendingAction,
controller.state?.application_control_session?.control_socket_open,
controller.stop,
registerDeactivation,
]);
@@ -8,6 +8,8 @@ import {
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
@@ -25,6 +27,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "control"
| "live"
| "replay"
| "stop"
@@ -167,7 +170,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state],
);
const prepareAndStartAcquisition = useCallback(
const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
[run],
);
const enterApplicationWorkspace = useCallback(
() =>
run("control", () =>
xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }),
),
[run],
);
const closeApplicationControlSession = useCallback(
() => run("control", () => xgridsK1Api.closeApplicationControlSession()),
[run],
);
const prepareAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
@@ -178,17 +200,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
}
if (plan === "already-running" && state) return state;
const prepared =
return (
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
: await xgridsK1Api.prepareAcquisition(request)
);
}),
[run, state],
);
const startPreparedAcquisition = useCallback(
(physicalAcceptance: OperatorPresenceConfirmation) =>
run("live", async () => {
const acquisition = state?.acquisition;
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: physicalAcceptance,
});
}),
[run, state],
@@ -200,22 +231,57 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
const stop = useCallback(
() =>
(physicalAcceptance?: OperatorPresenceConfirmation) =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
const softwareCommanded = isSoftwareCommandedAcquisition(state);
if (softwareCommanded && !physicalAcceptance) {
throw new ApiError(
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
);
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
mode: softwareCommanded ? "graceful" : "capture-only",
...(physicalAcceptance
? { physical_acceptance: physicalAcceptance }
: {}),
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state],
);
const confirmStoppedAtSteadyGreen = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
}
const stopOperation = [...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === "acquisition.stop" &&
operation.status === "operator_action_required",
);
if (!stopOperation) {
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "graceful",
operator_confirmed: true,
operation_id: stopOperation.operation_id,
});
}),
[run, state],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@@ -348,9 +414,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null),
scan,
connect,
prepareAndStartAcquisition,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
setObservationSourceActive,
updateViewerSettings,
+6 -2
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": [
{
+7 -8
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