200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-core/plugin-sdk";
|
|
|
|
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 shouldRenderSpatialControls(
|
|
state: XgridsK1State | null | undefined,
|
|
): boolean {
|
|
const acquisition = state?.acquisition;
|
|
if (!acquisition || state?.source_mode === "replay") return false;
|
|
return (
|
|
!isTerminalAcquisitionState(acquisition.state) ||
|
|
acquisition.cleanup_pending === true
|
|
);
|
|
}
|
|
|
|
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 isVendorWriteCapable(
|
|
state: XgridsK1State | null | undefined,
|
|
): boolean {
|
|
return (
|
|
state?.compatibility?.vendor_writes_enabled === true &&
|
|
state.compatibility.permitted_mode === "active-control"
|
|
);
|
|
}
|
|
|
|
export function isSoftwareCommandedAcquisition(
|
|
state: XgridsK1State | null | undefined,
|
|
): boolean {
|
|
return isVendorWriteCapable(state) && state?.acquisition?.control_mode === "plugin-commanded";
|
|
}
|
|
|
|
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()}`;
|
|
}
|