feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
] as const);
|
||||
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
|
||||
|
||||
export type DevicePluginApiVersion =
|
||||
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
|
||||
|
||||
export interface DeviceCapability {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -28,14 +37,24 @@ export interface DeviceModelDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifest {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
|
||||
export interface DeviceCompatibilityProfileDefinition {
|
||||
profileId: string;
|
||||
path: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
interface DevicePluginManifestBase {
|
||||
apiVersion: DevicePluginApiVersion;
|
||||
kind: "DevicePlugin";
|
||||
metadata: {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1";
|
||||
runtime: {
|
||||
@@ -48,6 +67,31 @@ export interface DevicePluginManifest {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha2";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export type DevicePluginManifest =
|
||||
| DevicePluginManifestV1Alpha1
|
||||
| DevicePluginManifestV1Alpha2;
|
||||
|
||||
export function isDevicePluginManifestV1Alpha2(
|
||||
manifest: DevicePluginManifest,
|
||||
): manifest is DevicePluginManifestV1Alpha2 {
|
||||
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
}
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
type DeviceCapability,
|
||||
type DeviceCompatibilityProfileDefinition,
|
||||
type DeviceModelDefinition,
|
||||
type DevicePluginActionDefinition,
|
||||
type DevicePluginManifest,
|
||||
type DevicePluginManifestV1Alpha1,
|
||||
type DevicePluginManifestV1Alpha2,
|
||||
} from "./contracts";
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
@@ -36,6 +40,14 @@ function text(value: unknown, path: string, maxLength = 160): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, path: string): string {
|
||||
const candidate = text(value, path, 192);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
|
||||
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function flag(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
||||
@@ -50,25 +62,44 @@ function list(value: unknown, path: string): unknown[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
function capability(value: unknown, path: string): DeviceCapability {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "label"]);
|
||||
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
|
||||
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
|
||||
return strictIdentifiers ? identifier(value, path) : text(value, path);
|
||||
}
|
||||
|
||||
function action(value: unknown, path: string): DevicePluginActionDefinition {
|
||||
function capability(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceCapability {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "label"]);
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
label: text(item.label, `${path}.label`),
|
||||
};
|
||||
}
|
||||
|
||||
function action(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DevicePluginActionDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
||||
return {
|
||||
id: text(item.id, `${path}.id`),
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
mutating: flag(item.mutating, `${path}.mutating`),
|
||||
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
|
||||
text(field, `${path}.secretFields[${index}]`),
|
||||
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function model(value: unknown, path: string): DeviceModelDefinition {
|
||||
function model(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceModelDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, [
|
||||
"id",
|
||||
@@ -87,14 +118,14 @@ function model(value: unknown, path: string): DeviceModelDefinition {
|
||||
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: text(item.id, `${path}.id`),
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
vendor: text(item.vendor, `${path}.vendor`),
|
||||
displayName: text(item.displayName, `${path}.displayName`),
|
||||
category: text(item.category, `${path}.category`),
|
||||
description: text(item.description, `${path}.description`, 1024),
|
||||
verified: flag(item.verified, `${path}.verified`),
|
||||
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
||||
capability(entry, `${path}.capabilities[${index}]`),
|
||||
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
|
||||
),
|
||||
ui: {
|
||||
slot,
|
||||
@@ -103,23 +134,96 @@ function model(value: unknown, path: string): DeviceModelDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function compatibilityProfile(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): DeviceCompatibilityProfileDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["profileId", "path", "modelId"]);
|
||||
const profilePath = text(item.path, `${path}.path`, 512);
|
||||
const pathSegments = profilePath.split("/");
|
||||
if (
|
||||
profilePath.startsWith("/") ||
|
||||
profilePath.includes("\\") ||
|
||||
!profilePath.endsWith(".json") ||
|
||||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
profileId: identifier(item.profileId, `${path}.profileId`),
|
||||
path: profilePath,
|
||||
modelId: identifier(item.modelId, `${path}.modelId`),
|
||||
};
|
||||
}
|
||||
|
||||
function validateV1Alpha2Profiles(
|
||||
models: readonly DeviceModelDefinition[],
|
||||
profiles: readonly DeviceCompatibilityProfileDefinition[],
|
||||
): void {
|
||||
if (!profiles.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
|
||||
}
|
||||
const modelIds = new Set(models.map((modelItem) => modelItem.id));
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const coveredModels = new Set<string>();
|
||||
for (const profile of profiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
|
||||
);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!modelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModels.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
|
||||
const root = record(document, "root");
|
||||
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
|
||||
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
|
||||
const apiVersion = root.apiVersion;
|
||||
if (
|
||||
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
|
||||
root.kind !== "DevicePlugin"
|
||||
) {
|
||||
throw new Error("Manifest использует несовместимую версию или kind.");
|
||||
}
|
||||
const metadata = record(root.metadata, "metadata");
|
||||
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
||||
const spec = record(root.spec, "spec");
|
||||
exactKeys(spec, "spec", [
|
||||
const specKeys = [
|
||||
"hostApiRange",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"actions",
|
||||
"models",
|
||||
]);
|
||||
if (spec.hostApiRange !== "v1alpha1") {
|
||||
];
|
||||
exactKeys(
|
||||
spec,
|
||||
"spec",
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
|
||||
? [...specKeys, "compatibilityProfiles"]
|
||||
: specKeys,
|
||||
);
|
||||
const expectedHostApiRange =
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
|
||||
if (spec.hostApiRange !== expectedHostApiRange) {
|
||||
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
|
||||
}
|
||||
const runtime = record(spec.runtime, "spec.runtime");
|
||||
@@ -129,45 +233,72 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife
|
||||
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
||||
}
|
||||
|
||||
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
const models = list(spec.models, "spec.models").map((entry, index) =>
|
||||
model(entry, `spec.models[${index}]`),
|
||||
model(entry, `spec.models[${index}]`, strictIdentifiers),
|
||||
);
|
||||
if (models.length !== 1) {
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
|
||||
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
|
||||
}
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
|
||||
}
|
||||
|
||||
const version = text(metadata.version, "metadata.version");
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
||||
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: DEVICE_PLUGIN_API_VERSION,
|
||||
const common = {
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: text(metadata.id, "metadata.id"),
|
||||
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
|
||||
version,
|
||||
displayName: text(metadata.displayName, "metadata.displayName"),
|
||||
},
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1",
|
||||
runtime: {
|
||||
backendEntrypoint: text(
|
||||
runtime.backendEntrypoint,
|
||||
"spec.runtime.backendEntrypoint",
|
||||
256,
|
||||
),
|
||||
isolation,
|
||||
},
|
||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||
text(entry, `spec.permissions[${index}]`),
|
||||
} as const;
|
||||
const commonSpec = {
|
||||
runtime: {
|
||||
backendEntrypoint: text(
|
||||
runtime.backendEntrypoint,
|
||||
"spec.runtime.backendEntrypoint",
|
||||
256,
|
||||
),
|
||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||
action(entry, `spec.actions[${index}]`),
|
||||
),
|
||||
models,
|
||||
isolation,
|
||||
},
|
||||
};
|
||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||
action(entry, `spec.actions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
models,
|
||||
} as const;
|
||||
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha1",
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha1;
|
||||
}
|
||||
|
||||
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
|
||||
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
|
||||
);
|
||||
validateV1Alpha2Profiles(models, profiles);
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha2",
|
||||
compatibilityProfiles: profiles,
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha2;
|
||||
}
|
||||
|
||||
export function requirePluginAction(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_STATE_READ_ACTION_ID,
|
||||
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
|
||||
isDevicePluginManifestV1Alpha2,
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
@@ -20,7 +22,7 @@ export function createDevicePluginRegistry(
|
||||
|
||||
for (const plugin of installedPlugins) {
|
||||
const { manifest } = plugin;
|
||||
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
|
||||
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
||||
);
|
||||
@@ -35,11 +37,17 @@ export function createDevicePluginRegistry(
|
||||
}
|
||||
pluginIds.add(manifest.metadata.id);
|
||||
|
||||
if (manifest.spec.models.length !== 1) {
|
||||
if (
|
||||
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
manifest.spec.models.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
||||
);
|
||||
}
|
||||
if (!manifest.spec.models.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
|
||||
}
|
||||
|
||||
const permissions = new Set(manifest.spec.permissions);
|
||||
if (permissions.size !== manifest.spec.permissions.length) {
|
||||
@@ -84,6 +92,37 @@ export function createDevicePluginRegistry(
|
||||
}
|
||||
models.push({ plugin, model, ConnectionView });
|
||||
}
|
||||
|
||||
if (isDevicePluginManifestV1Alpha2(manifest)) {
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
|
||||
const coveredModelIds = new Set<string>();
|
||||
if (!manifest.spec.compatibilityProfiles.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
|
||||
}
|
||||
for (const profile of manifest.spec.compatibilityProfiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!declaredModelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModelIds.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...declaredModelIds].filter(
|
||||
(modelId) => !coveredModelIds.has(modelId),
|
||||
);
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
|
||||
@@ -39,6 +39,32 @@ export interface ActiveDeviceSnapshot {
|
||||
endpointLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeDeviceSessionSnapshot {
|
||||
sessionId: string;
|
||||
deviceId: string;
|
||||
compatibilityProfileId?: string | null;
|
||||
connectivity?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeAcquisitionSnapshot {
|
||||
acquisitionId: string;
|
||||
deviceId: string;
|
||||
deviceSessionId: string;
|
||||
compatibilityProfileId: string;
|
||||
controlMode: string;
|
||||
state: string;
|
||||
stateRevision: number;
|
||||
operatorInstructions: readonly string[];
|
||||
}
|
||||
|
||||
export interface RuntimeOperationSnapshot {
|
||||
operationId: string;
|
||||
action: string;
|
||||
status: string;
|
||||
stageCode?: string | null;
|
||||
messageCode?: string | null;
|
||||
}
|
||||
|
||||
export interface SpatialSourceDescriptor {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -50,6 +76,9 @@ export interface MissionRuntimeState {
|
||||
phase: RuntimePhase;
|
||||
message?: string | null;
|
||||
activeDevice?: ActiveDeviceSnapshot | null;
|
||||
deviceSession?: RuntimeDeviceSessionSnapshot | null;
|
||||
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||
operations?: readonly RuntimeOperationSnapshot[];
|
||||
spatialSource?: SpatialSourceDescriptor | null;
|
||||
viewerSettings?: ViewerSettings | null;
|
||||
sourceMode: SourceMode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
|
||||
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
|
||||
import { MetricCard } from "../../components/MetricCard";
|
||||
import type { BleDevice } from "./api";
|
||||
import type { BleDevice, CompatibilityAttestation } from "./api";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
isSourceRuntimeBusy,
|
||||
provisioningIntentKey,
|
||||
recoverableAcquisition,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
backendLabel,
|
||||
@@ -23,7 +30,6 @@ import {
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
sourceModeLabel,
|
||||
} from "./presentation";
|
||||
import { useXgridsK1Controller } from "./runtimeContext";
|
||||
|
||||
@@ -34,6 +40,12 @@ const sessionItems = [
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
operator_confirmed: true,
|
||||
};
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
@@ -92,7 +104,7 @@ function DeviceRow({
|
||||
<div>
|
||||
<span className="device-row__name">
|
||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
||||
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
|
||||
</span>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
@@ -145,23 +157,30 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
prepareAndStartAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
abort,
|
||||
} = console;
|
||||
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const provisioningIntentRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +193,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
setSessionIntent(state.source_mode);
|
||||
} else if (state?.acquisition?.state === "prepared") {
|
||||
setSessionIntent("live");
|
||||
}
|
||||
}, [state?.acquisition?.state, state?.source_mode]);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const streamActive = confirmedLive || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
@@ -183,9 +211,44 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||
const sourceLabel = sourceModeLabel(state?.source_mode);
|
||||
const canConnect =
|
||||
powerConfirmed &&
|
||||
profileConfirmed &&
|
||||
selectedDeviceId.length > 0 &&
|
||||
credentialsReady &&
|
||||
!isBusy;
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
|
||||
const effectiveSessionIntent: SessionIntent =
|
||||
state?.source_mode === "live" || state?.source_mode === "replay"
|
||||
? state.source_mode
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const liveTargetReady = Boolean(
|
||||
state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host,
|
||||
);
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed =
|
||||
state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: confirmedLive || state?.source_mode === "replay"
|
||||
? "success"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionPhaseLabel =
|
||||
sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase);
|
||||
const connectionPhaseTone =
|
||||
sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase);
|
||||
const selectableSessionItems = useMemo(
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
@@ -194,17 +257,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||
provisioningIntentRef.current = idempotencyKey;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
idempotency_key: idempotencyKey,
|
||||
});
|
||||
if (succeeded) setPassword("");
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
setPassword("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitLive = async () => {
|
||||
if (!profileConfirmed || sourceRuntimeBusy) return;
|
||||
const targetHost = liveHost.trim();
|
||||
const started = await startLive(targetHost ? { host: targetHost } : {});
|
||||
const started = await prepareAndStartAcquisition({
|
||||
...(targetHost ? { host: targetHost } : {}),
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
});
|
||||
if (started) host.openSpatialScene();
|
||||
};
|
||||
|
||||
@@ -229,7 +303,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
Обновить состояние
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Закрыть
|
||||
@@ -248,7 +322,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -286,7 +360,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
@@ -303,7 +377,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={setPowerConfirmed}
|
||||
onChange={(checked) => {
|
||||
setPowerConfirmed(checked);
|
||||
if (!checked) {
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
@@ -327,8 +407,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
|
||||
профиль — только подсказка; окончательный выбор всегда делает оператор.
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
|
||||
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
|
||||
и аттестацию всегда делает оператор.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
@@ -337,6 +418,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => {
|
||||
setSelectedDeviceId("");
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
void scan();
|
||||
}}
|
||||
>
|
||||
@@ -351,7 +434,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => setSelectedDeviceId(device.device_id)}
|
||||
onSelect={() => {
|
||||
setSelectedDeviceId(device.device_id);
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -370,11 +457,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">
|
||||
Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
|
||||
или в официальном приложении и подтвердите только точное соответствие профилю.
|
||||
</span>
|
||||
<Checker
|
||||
checked={profileConfirmed}
|
||||
label="Я вручную подтвердил FW 3.0.2 и прямое подключение в локальной сети"
|
||||
onChange={(checked) => {
|
||||
setProfileConfirmed(checked);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setSsid(event.target.value);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Сеть локального контура"
|
||||
@@ -384,7 +488,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
@@ -415,23 +522,25 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</span>
|
||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
<StatusBadge tone={sourceTone}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Источник данных"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
value={effectiveSessionIntent}
|
||||
items={selectableSessionItems}
|
||||
onChange={(intent) => {
|
||||
if (!sessionLocked) setSessionIntent(intent);
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionIntent === "live" ? (
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
@@ -439,20 +548,36 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
placeholder={
|
||||
state?.k1_ip ||
|
||||
preparedAcquisition?.target_host ||
|
||||
"Сначала подключите устройство к Wi‑Fi"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!liveTargetReady ||
|
||||
sourceRuntimeBusy ||
|
||||
(activeAcquisition !== null && preparedAcquisition === null)
|
||||
}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
{pendingAction === "live"
|
||||
? "Подготавливаем приём…"
|
||||
: preparedAcquisition
|
||||
? "Продолжить подготовленный приём"
|
||||
: "Подготовить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{liveTargetReady
|
||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
|
||||
: liveTargetReady
|
||||
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -485,7 +610,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
disabled={isBusy || sessionLocked || replayPath.trim().length === 0}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
@@ -494,14 +619,41 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>Статус изменится только после ответа локального сервиса.</p>
|
||||
<p>
|
||||
{state?.source_mode === "replay"
|
||||
? "Остановка завершит фактически запущенный повтор записи."
|
||||
: activeAcquisition || state?.source_mode === "live"
|
||||
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||
{pendingAction === "stop"
|
||||
? state?.source_mode === "replay"
|
||||
? "Останавливаем повтор…"
|
||||
: "Останавливаем локальный приём…"
|
||||
: state?.source_mode === "replay"
|
||||
? "Остановить повтор"
|
||||
: preparedAcquisition
|
||||
? "Завершить подготовленный приём"
|
||||
: "Остановить локальный приём"}
|
||||
</Button>
|
||||
{activeAcquisition ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
onClick={() => void abort()}
|
||||
>
|
||||
{pendingAction === "abort"
|
||||
? "Прерываем локальную операцию…"
|
||||
: preparedAcquisition
|
||||
? "Отменить подготовку"
|
||||
: "Аварийно завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
|
||||
@@ -14,6 +14,101 @@ export interface BleDevice {
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export type AcquisitionState =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "awaiting_external_start"
|
||||
| "starting"
|
||||
| "acquiring"
|
||||
| "awaiting_external_stop"
|
||||
| "stopping"
|
||||
| "finalizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted"
|
||||
| "interrupted";
|
||||
|
||||
export type OperationStatus =
|
||||
| "accepted"
|
||||
| "running"
|
||||
| "operator_action_required"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out"
|
||||
| "interrupted";
|
||||
|
||||
export interface XgridsDeviceRef {
|
||||
device_id: string;
|
||||
model_id: string;
|
||||
identity_stability: "stable" | "provisional";
|
||||
identity_basis:
|
||||
| "hardware-identifier"
|
||||
| "plugin-derived"
|
||||
| "operator-assigned"
|
||||
| "transport-local";
|
||||
transport_alias?: string | null;
|
||||
}
|
||||
|
||||
export interface XgridsDeviceSession {
|
||||
device_session_id: string;
|
||||
device_id: string;
|
||||
opened_at?: string | null;
|
||||
compatibility_profile_id?: string | null;
|
||||
connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded";
|
||||
}
|
||||
|
||||
export interface XgridsCompatibilityState {
|
||||
profile_id?: string | null;
|
||||
decision?: "compatible" | "limited" | "unknown" | "incompatible";
|
||||
permitted_mode?: "blocked" | "evidence-only" | "read-only" | "active-control";
|
||||
firmware_claim?: string | null;
|
||||
vendor_writes_enabled?: boolean;
|
||||
camera_preview?: string | null;
|
||||
}
|
||||
|
||||
export interface XgridsAcquisition {
|
||||
schema_version?: string;
|
||||
acquisition_id: string;
|
||||
device_id: string;
|
||||
device_session_id: string;
|
||||
compatibility_profile_id: string;
|
||||
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
||||
requested_streams: string[];
|
||||
target_host: string;
|
||||
duration_seconds: number;
|
||||
evidence_policy: "required" | "best-effort" | "disabled";
|
||||
state: AcquisitionState;
|
||||
state_revision: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
message_code?: string | null;
|
||||
operator_instructions?: string[];
|
||||
result?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface XgridsOperation {
|
||||
schema_version?: string;
|
||||
operation_id: string;
|
||||
action: string;
|
||||
status: OperationStatus;
|
||||
accepted_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
deadline_at?: string | null;
|
||||
device_id?: string | null;
|
||||
device_session_id?: string | null;
|
||||
idempotency_key?: string | null;
|
||||
stage_code?: string | null;
|
||||
message_code?: string | null;
|
||||
sequence?: number;
|
||||
state_revision?: number;
|
||||
cancellable?: boolean;
|
||||
cancel_requested?: boolean;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: Record<string, unknown> | null;
|
||||
evidence_refs?: string[];
|
||||
}
|
||||
|
||||
export interface XgridsK1Metrics {
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
@@ -28,6 +123,7 @@ export interface XgridsK1Metrics {
|
||||
}
|
||||
|
||||
export interface XgridsK1State {
|
||||
contract_version?: string | null;
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
devices?: BleDevice[];
|
||||
@@ -39,6 +135,12 @@ export interface XgridsK1State {
|
||||
viewer_settings?: ViewerSettings | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: XgridsK1Metrics;
|
||||
compatibility?: XgridsCompatibilityState | null;
|
||||
device_ref?: XgridsDeviceRef | null;
|
||||
device_session?: XgridsDeviceSession | null;
|
||||
acquisition?: XgridsAcquisition | null;
|
||||
operations?: XgridsOperation[];
|
||||
last_operation?: XgridsOperation | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
@@ -52,15 +154,66 @@ export interface ScanRequest {
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface CompatibilityAttestation {
|
||||
firmware_version: "3.0.2";
|
||||
topology: "direct-lan";
|
||||
operator_confirmed: true;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
}
|
||||
|
||||
export interface LiveRequest {
|
||||
export interface PrepareAcquisitionRequest {
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
requested_streams?: RequestedStreamId[];
|
||||
evidence_policy?: "required" | "best-effort" | "disabled";
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
}
|
||||
|
||||
export type RequestedStreamId =
|
||||
| "spatial.point-cloud.live"
|
||||
| "spatial.pose.live"
|
||||
| "device.status.live"
|
||||
| "device.heartbeat.live";
|
||||
|
||||
export interface StartAcquisitionRequest {
|
||||
acquisition_id: string;
|
||||
expected_state_revision?: number;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
}
|
||||
|
||||
export interface StopAcquisitionRequest {
|
||||
acquisition_id: string;
|
||||
mode: "capture-only" | "graceful";
|
||||
operator_confirmed?: boolean;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
}
|
||||
|
||||
export interface AbortAcquisitionRequest {
|
||||
acquisition_id: string;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
deadline_seconds?: number;
|
||||
}
|
||||
|
||||
export interface CompatibilityLiveRequest {
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
@@ -177,16 +330,32 @@ export const xgridsK1Api = {
|
||||
return invokeState(xgridsK1Actions.networkProvision, body);
|
||||
},
|
||||
|
||||
startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStartLive, body);
|
||||
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
|
||||
},
|
||||
|
||||
startAcquisition(body: StartAcquisitionRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.acquisitionStart, body);
|
||||
},
|
||||
|
||||
stopAcquisition(body: StopAcquisitionRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.acquisitionStop, body);
|
||||
},
|
||||
|
||||
abortAcquisition(body: AbortAcquisitionRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.acquisitionAbort, body);
|
||||
},
|
||||
|
||||
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStartReplay, body);
|
||||
},
|
||||
|
||||
stopSession(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStop);
|
||||
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
|
||||
},
|
||||
|
||||
stopSessionCompatibility(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.compatibilityStreamStop);
|
||||
},
|
||||
|
||||
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts";
|
||||
|
||||
import type {
|
||||
AcquisitionState,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
XgridsOperation,
|
||||
} from "./api";
|
||||
|
||||
const TERMINAL_ACQUISITION_STATES = new Set<AcquisitionState>([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
const FAILED_OPERATION_STATUSES = new Set([
|
||||
"failed",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
|
||||
|
||||
export function isTerminalAcquisitionState(
|
||||
state: AcquisitionState | null | undefined,
|
||||
): boolean {
|
||||
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
|
||||
}
|
||||
|
||||
export function recoverableAcquisition(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): XgridsAcquisition | null {
|
||||
const acquisition = state?.acquisition;
|
||||
return acquisition && !isTerminalAcquisitionState(acquisition.state) ? acquisition : null;
|
||||
}
|
||||
|
||||
export function isConfirmedLiveState(state: XgridsK1State | null | undefined): boolean {
|
||||
return state?.source_mode === "live" && state.acquisition?.state === "acquiring";
|
||||
}
|
||||
|
||||
export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): boolean {
|
||||
return state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
}
|
||||
|
||||
export function confirmedRuntimeSourceMode(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): RuntimeSourceMode {
|
||||
if (state?.source_mode === "replay") return "replay";
|
||||
if (isConfirmedLiveState(state)) return "live";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function effectiveAcquisition(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): XgridsAcquisition | null {
|
||||
if (state?.source_mode === "replay") return null;
|
||||
return state?.acquisition ?? null;
|
||||
}
|
||||
|
||||
export function liveStartPlan(state: XgridsK1State | null | undefined): LiveStartPlan {
|
||||
if (state?.source_mode === "replay") return "blocked";
|
||||
const acquisition = recoverableAcquisition(state);
|
||||
if (!acquisition) return state?.source_mode === "live" ? "blocked" : "prepare";
|
||||
if (acquisition.state === "prepared") return "resume-prepared";
|
||||
if (
|
||||
acquisition.state === "starting" ||
|
||||
acquisition.state === "awaiting_external_start" ||
|
||||
acquisition.state === "acquiring"
|
||||
) {
|
||||
return "already-running";
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
export function normalizeRuntimePhase(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): RuntimePhase {
|
||||
const phase = state?.phase;
|
||||
const acquisitionState = effectiveAcquisition(state)?.state;
|
||||
if (acquisitionState === "failed" || acquisitionState === "interrupted") return "error";
|
||||
if (acquisitionState === "awaiting_external_start" || acquisitionState === "starting") {
|
||||
return "starting";
|
||||
}
|
||||
if (acquisitionState === "acquiring") return "streaming";
|
||||
if (
|
||||
acquisitionState === "awaiting_external_stop" ||
|
||||
acquisitionState === "stopping" ||
|
||||
acquisitionState === "finalizing"
|
||||
) {
|
||||
return "stopping";
|
||||
}
|
||||
if (acquisitionState === "prepared") return "connected";
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "connected") return "connected";
|
||||
if (phase === "starting_live") return "starting";
|
||||
if (phase === "live") return "streaming";
|
||||
if (phase === "replay") return "replaying";
|
||||
if (phase === "stopping") return "stopping";
|
||||
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
|
||||
return "configuring";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function spatialSourceId(
|
||||
state: XgridsK1State | null | undefined,
|
||||
sourceUrl: string,
|
||||
): string | null {
|
||||
if (!sourceUrl) return null;
|
||||
if (state?.source_mode === "replay") return `replay:${sourceUrl}`;
|
||||
return state?.acquisition?.acquisition_id ?? sourceUrl;
|
||||
}
|
||||
|
||||
export function sourceStatusLabel(state: XgridsK1State | null | undefined): string {
|
||||
if (state?.source_mode === "replay") return "Повтор записи";
|
||||
if (isConfirmedLiveState(state)) return "Реальное время · данные подтверждены";
|
||||
if (state?.source_mode === "live") {
|
||||
if (state.acquisition?.state === "failed" || state.phase === "error") {
|
||||
return "Ошибка локального приёмника";
|
||||
}
|
||||
return "Ожидание реальных данных";
|
||||
}
|
||||
if (state?.acquisition?.state === "prepared") return "Приём подготовлен";
|
||||
return "Ожидание";
|
||||
}
|
||||
|
||||
export function operationByIdempotencyKey(
|
||||
state: XgridsK1State | null | undefined,
|
||||
action: string,
|
||||
idempotencyKey: string | null | undefined,
|
||||
): XgridsOperation | null {
|
||||
if (!idempotencyKey) return null;
|
||||
return (
|
||||
[...(state?.operations ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(operation) =>
|
||||
operation.action === action && operation.idempotency_key === idempotencyKey,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function operationNeedsReconciliation(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): boolean {
|
||||
if (!operation || !FAILED_OPERATION_STATUSES.has(operation.status)) return false;
|
||||
return operation.error?.safe_to_retry !== true;
|
||||
}
|
||||
|
||||
function defaultUuid(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (!cryptoApi) {
|
||||
throw new Error("Web Crypto недоступен; безопасный идентификатор операции не создан.");
|
||||
}
|
||||
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||
.slice(6, 8)
|
||||
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
|
||||
export function provisioningIntentKey(
|
||||
current: string | null,
|
||||
createUuid: () => string = defaultUuid,
|
||||
): string {
|
||||
return current ?? `network-provision:${createUuid()}`;
|
||||
}
|
||||
@@ -11,9 +11,21 @@ export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
|
||||
export const xgridsK1Actions = Object.freeze({
|
||||
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
|
||||
discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"),
|
||||
deviceInspect: requirePluginAction(xgridsK1Manifest, "device.inspect"),
|
||||
sensorCatalogRead: requirePluginAction(xgridsK1Manifest, "sensor.catalog.read"),
|
||||
deviceCalibrationSnapshotRead: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"calibration.device-snapshot.read",
|
||||
),
|
||||
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
|
||||
streamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
|
||||
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
|
||||
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
|
||||
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
|
||||
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
|
||||
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
|
||||
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
|
||||
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
|
||||
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
|
||||
streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
||||
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
||||
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
|
||||
});
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
import type {
|
||||
MissionRuntimeController,
|
||||
MissionRuntimeState,
|
||||
RuntimePhase,
|
||||
} from "../../core/runtime/contracts";
|
||||
import {
|
||||
confirmedRuntimeSourceMode,
|
||||
effectiveAcquisition,
|
||||
normalizeRuntimePhase,
|
||||
spatialSourceId,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import { finiteMetric, pipelineLatency } from "./presentation";
|
||||
@@ -19,19 +24,6 @@ export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
||||
|
||||
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
|
||||
|
||||
function normalizePhase(phase: string | null | undefined): RuntimePhase {
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "connected") return "connected";
|
||||
if (phase === "starting_live") return "starting";
|
||||
if (phase === "live") return "streaming";
|
||||
if (phase === "replay") return "replaying";
|
||||
if (phase === "stopping") return "stopping";
|
||||
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
|
||||
return "configuring";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function normalizeState(
|
||||
controller: XgridsK1Controller,
|
||||
activeModel: DeviceModelDefinition,
|
||||
@@ -39,31 +31,67 @@ function normalizeState(
|
||||
const state = controller.state;
|
||||
if (!state) return null;
|
||||
const metrics = state.metrics;
|
||||
const hasDevice = Boolean(state.selected_device_id || state.k1_ip);
|
||||
const deviceRef = state.device_ref;
|
||||
const deviceSession = state.device_session;
|
||||
const acquisition = effectiveAcquisition(state);
|
||||
const sourceUrl = state.rerun_grpc_url?.trim() ?? "";
|
||||
const resolvedSpatialSourceId =
|
||||
state.source_mode === "replay"
|
||||
? spatialSourceId(state, sourceUrl)
|
||||
: acquisition?.acquisition_id ?? spatialSourceId(state, sourceUrl);
|
||||
|
||||
return {
|
||||
phase: normalizePhase(state.phase),
|
||||
phase: normalizeRuntimePhase(state),
|
||||
message: localizeRuntimeMessage(state.message),
|
||||
activeDevice: hasDevice
|
||||
activeDevice: deviceRef
|
||||
? {
|
||||
pluginId: xgridsK1Manifest.metadata.id,
|
||||
modelId: activeModel.id,
|
||||
modelId: deviceRef.model_id || activeModel.id,
|
||||
displayName: activeModel.displayName,
|
||||
instanceId: state.selected_device_id,
|
||||
instanceId: deviceRef.device_id,
|
||||
endpointLabel: state.k1_ip,
|
||||
}
|
||||
: null,
|
||||
spatialSource: sourceUrl
|
||||
deviceSession: deviceSession
|
||||
? {
|
||||
id: "xgrids-k1-rerun-live",
|
||||
sessionId: deviceSession.device_session_id,
|
||||
deviceId: deviceSession.device_id,
|
||||
compatibilityProfileId: deviceSession.compatibility_profile_id,
|
||||
connectivity: deviceSession.connectivity,
|
||||
}
|
||||
: null,
|
||||
acquisition: acquisition
|
||||
? {
|
||||
acquisitionId: acquisition.acquisition_id,
|
||||
deviceId: acquisition.device_id,
|
||||
deviceSessionId: acquisition.device_session_id,
|
||||
compatibilityProfileId: acquisition.compatibility_profile_id,
|
||||
controlMode: acquisition.control_mode,
|
||||
state: acquisition.state,
|
||||
stateRevision: acquisition.state_revision,
|
||||
operatorInstructions: acquisition.operator_instructions ?? [],
|
||||
}
|
||||
: null,
|
||||
operations: (state.operations ?? []).map((operation) => ({
|
||||
operationId: operation.operation_id,
|
||||
action: operation.action,
|
||||
status: operation.status,
|
||||
stageCode: operation.stage_code,
|
||||
messageCode: operation.message_code,
|
||||
})),
|
||||
spatialSource: sourceUrl && resolvedSpatialSourceId
|
||||
? {
|
||||
id: resolvedSpatialSourceId,
|
||||
url: sourceUrl,
|
||||
label: "Локальный пространственный поток",
|
||||
label:
|
||||
state.source_mode === "replay"
|
||||
? "Повтор пространственной записи"
|
||||
: "Локальный пространственный поток",
|
||||
kind: "rerun-grpc",
|
||||
}
|
||||
: null,
|
||||
viewerSettings: state.viewer_settings,
|
||||
sourceMode: state.source_mode ?? "idle",
|
||||
sourceMode: confirmedRuntimeSourceMode(state),
|
||||
metrics: {
|
||||
latencyMs: pipelineLatency(metrics),
|
||||
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
|
||||
|
||||
@@ -7,14 +7,27 @@ import {
|
||||
xgridsK1Api,
|
||||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type XgridsK1State,
|
||||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type PrepareAcquisitionRequest,
|
||||
type ReplayRequest,
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
isTerminalAcquisitionState,
|
||||
liveStartPlan,
|
||||
operationByIdempotencyKey,
|
||||
operationNeedsReconciliation,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
export type PendingAction =
|
||||
| "scan"
|
||||
| "connect"
|
||||
| "live"
|
||||
| "replay"
|
||||
| "stop"
|
||||
| "abort"
|
||||
| "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -114,13 +127,64 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
|
||||
[run],
|
||||
(request: ConnectRequest) =>
|
||||
run("connect", async () => {
|
||||
const previous = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
if (previous?.status === "succeeded" && state) return state;
|
||||
if (operationNeedsReconciliation(previous)) {
|
||||
throw new ApiError(
|
||||
"Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.",
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = await xgridsK1Api.connect(request);
|
||||
const operation = operationByIdempotencyKey(
|
||||
nextState,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
if (operationNeedsReconciliation(operation)) {
|
||||
throw new ApiError(
|
||||
"Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.",
|
||||
);
|
||||
}
|
||||
if (operation && operation.status !== "succeeded") {
|
||||
throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния.");
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const startLive = useCallback(
|
||||
(request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
|
||||
[run],
|
||||
const prepareAndStartAcquisition = useCallback(
|
||||
(request: PrepareAcquisitionRequest) =>
|
||||
run("live", async () => {
|
||||
const plan = liveStartPlan(state);
|
||||
if (plan === "blocked") {
|
||||
throw new ApiError(
|
||||
"Сначала завершите текущий приём или повтор записи.",
|
||||
);
|
||||
}
|
||||
if (plan === "already-running" && state) return state;
|
||||
|
||||
const prepared =
|
||||
plan === "resume-prepared" && state
|
||||
? state
|
||||
: await xgridsK1Api.prepareAcquisition(request);
|
||||
const acquisition = prepared.acquisition;
|
||||
if (!acquisition?.acquisition_id) {
|
||||
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
|
||||
}
|
||||
return xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const startReplay = useCallback(
|
||||
@@ -129,10 +193,32 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() => run("stop", () => xgridsK1Api.stopSession()),
|
||||
[run],
|
||||
() =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
|
||||
if (acquisition && !acquisitionTerminal) {
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "capture-only",
|
||||
});
|
||||
}
|
||||
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
|
||||
return xgridsK1Api.stopSessionCompatibility();
|
||||
}),
|
||||
[run, state?.acquisition],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return run("abort", () =>
|
||||
xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }),
|
||||
);
|
||||
}, [run, state?.acquisition]);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
||||
[run],
|
||||
@@ -210,9 +296,10 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
prepareAndStartAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
abort,
|
||||
updateViewerSettings,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user