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