feat(device-plugins): add profiled K1 lifecycle and canonical data plane

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 19:44:06 +03:00
parent 19ab973110
commit e6f7648b84
45 changed files with 6401 additions and 440 deletions
@@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</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" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</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,
};
}