279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import {
|
||
isXgridsActiveStreamRecovery,
|
||
type XgridsActiveStreamRecovery,
|
||
type XgridsK1State,
|
||
} from "./api";
|
||
|
||
export interface ActiveStreamRecoveryLineage {
|
||
snapshotRuntimeId: string;
|
||
acquisitionId: string;
|
||
acquisitionStateRevision: number;
|
||
recoveryGeneration: number;
|
||
runtimeProducerGeneration: number;
|
||
recovery: XgridsActiveStreamRecovery;
|
||
}
|
||
|
||
export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage;
|
||
|
||
export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage;
|
||
|
||
export type ActiveStreamRecoveryVisibleState =
|
||
| "reconnecting"
|
||
| "blocked"
|
||
| "standby"
|
||
| "fault";
|
||
|
||
export interface ActiveStreamRecoveryPresentation {
|
||
state: ActiveStreamRecoveryVisibleState;
|
||
eyebrow: string;
|
||
title: string;
|
||
statusLabel: string;
|
||
tone: "neutral" | "warning" | "danger";
|
||
detail: string;
|
||
progressLabel: string | null;
|
||
showSpinner: boolean;
|
||
forceFinishAvailable: boolean;
|
||
}
|
||
|
||
function positiveInteger(value: unknown): value is number {
|
||
return Number.isInteger(value) && (value as number) > 0;
|
||
}
|
||
|
||
/**
|
||
* Resolve one exact active-stream lineage from the public runtime snapshot.
|
||
*
|
||
* A recovery-shaped object alone is not authority. The browser also requires
|
||
* the current runtime id, the same acquisition id and the exact producer
|
||
* generation on both sides of the projection. This keeps a late recovery
|
||
* update from an older producer out of both presentation and mutation gates.
|
||
*/
|
||
export function exactActiveStreamRecoveryLineage(
|
||
state: XgridsK1State | null | undefined,
|
||
): ActiveStreamRecoveryLineage | null {
|
||
const recovery = state?.connection_recovery;
|
||
const acquisition = state?.acquisition;
|
||
const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
|
||
const producerGeneration = state?.producer_generation;
|
||
const acquisitionId = acquisition?.acquisition_id?.trim() || null;
|
||
const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null;
|
||
if (
|
||
!snapshotRuntimeId
|
||
|| !isXgridsActiveStreamRecovery(recovery)
|
||
|| !acquisition
|
||
|| !acquisitionId
|
||
|| recoveryAcquisitionId !== acquisitionId
|
||
|| !positiveInteger(acquisition.state_revision)
|
||
|| !positiveInteger(recovery.generation)
|
||
|| !positiveInteger(producerGeneration)
|
||
|| recovery.runtime_producer_generation !== producerGeneration
|
||
|| recovery.automatic_read_only_rebind !== true
|
||
) return null;
|
||
return {
|
||
snapshotRuntimeId,
|
||
acquisitionId,
|
||
acquisitionStateRevision: acquisition.state_revision,
|
||
recoveryGeneration: recovery.generation,
|
||
runtimeProducerGeneration: producerGeneration,
|
||
recovery,
|
||
};
|
||
}
|
||
|
||
/** Exact, current and backend-policy-admitted authority for local-only finish. */
|
||
export function activeStreamForceFinishAuthority(
|
||
state: XgridsK1State | null | undefined,
|
||
): ActiveStreamForceFinishAuthority | null {
|
||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||
if (
|
||
!lineage
|
||
|| !["reconnecting", "blocked"].includes(lineage.recovery.state)
|
||
|| lineage.recovery.force_finish_allowed !== true
|
||
|| state?.phase !== "reconnecting"
|
||
|| state.source_mode !== "live"
|
||
|| ![
|
||
"starting",
|
||
"awaiting_external_start",
|
||
"acquiring",
|
||
].includes(state.acquisition?.state ?? "")
|
||
) return null;
|
||
return lineage;
|
||
}
|
||
|
||
/**
|
||
* Exact authority for retaining browser presentation while the backend owns a
|
||
* read-only reconnect. This is deliberately narrower than the recovery card:
|
||
* terminal/blocked recovery states and an inactive acquisition cannot retain
|
||
* a prior spatial or camera transport.
|
||
*/
|
||
export function activeStreamRecoveryPresentationAuthority(
|
||
state: XgridsK1State | null | undefined,
|
||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||
if (
|
||
!lineage
|
||
|| lineage.recovery.state !== "reconnecting"
|
||
|| state?.phase !== "reconnecting"
|
||
|| state.source_mode !== "live"
|
||
|| ![
|
||
"starting",
|
||
"awaiting_external_start",
|
||
"acquiring",
|
||
].includes(state.acquisition?.state ?? "")
|
||
) return null;
|
||
return lineage;
|
||
}
|
||
|
||
/**
|
||
* Keep the exact recovered lineage available to disposable browser receivers
|
||
* after the recovery card has disappeared. Spatial admission can complete on
|
||
* the first authoritative PCL before the acquisition-owned camera produces
|
||
* its first playable frame. This authority carries only the no-write
|
||
* presentation lease: it grants neither force-finish nor START/STOP policy.
|
||
*/
|
||
export function activeStreamRecoveredBrowserAuthority(
|
||
state: XgridsK1State | null | undefined,
|
||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||
if (
|
||
!lineage
|
||
|| lineage.recovery.state !== "recovered"
|
||
|| lineage.recovery.camera_recovery !== "owned"
|
||
|| state?.phase !== "live"
|
||
|| state.source_mode !== "live"
|
||
|| state.acquisition?.state !== "acquiring"
|
||
) return null;
|
||
return lineage;
|
||
}
|
||
|
||
/**
|
||
* While a validated recovery contract is active it owns the presentation
|
||
* decision. Ordinary supervisor data flags may be stale across the network
|
||
* gap, so only an exact reconnect lease can retain browser transports.
|
||
*/
|
||
export function activeStreamRecoveryOwnsPresentationDecision(
|
||
state: XgridsK1State | null | undefined,
|
||
): boolean {
|
||
const recovery = state?.connection_recovery;
|
||
return Boolean(
|
||
isXgridsActiveStreamRecovery(recovery)
|
||
&& !["inactive", "recovered"].includes(recovery.state),
|
||
);
|
||
}
|
||
|
||
export function activeStreamForceFinishAuthorityMatches(
|
||
expected: ActiveStreamForceFinishAuthority,
|
||
state: XgridsK1State | null | undefined,
|
||
): boolean {
|
||
const current = activeStreamForceFinishAuthority(state);
|
||
return Boolean(
|
||
current
|
||
&& current.snapshotRuntimeId === expected.snapshotRuntimeId
|
||
&& current.acquisitionId === expected.acquisitionId
|
||
&& current.acquisitionStateRevision === expected.acquisitionStateRevision
|
||
&& current.recoveryGeneration === expected.recoveryGeneration
|
||
&& current.runtimeProducerGeneration === expected.runtimeProducerGeneration,
|
||
);
|
||
}
|
||
|
||
export function formatActiveStreamRecoveryElapsed(
|
||
elapsedMs: number | null,
|
||
): string | null {
|
||
if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null;
|
||
const elapsedSeconds = Math.floor(elapsedMs / 1_000);
|
||
if (elapsedSeconds < 60) return `${elapsedSeconds} с`;
|
||
const minutes = Math.floor(elapsedSeconds / 60);
|
||
const seconds = elapsedSeconds % 60;
|
||
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
|
||
}
|
||
|
||
function recoveryProgressLabel(
|
||
recovery: XgridsActiveStreamRecovery,
|
||
): string | null {
|
||
const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms);
|
||
const attempt = recovery.attempt > 0
|
||
? `Попытка ${recovery.attempt}`
|
||
: "Подготовка проверки";
|
||
return elapsed ? `${attempt} · ${elapsed}` : attempt;
|
||
}
|
||
|
||
/**
|
||
* Present only an exact current lineage. `recovered` deliberately returns
|
||
* null so the ordinary confirmed live UI resumes without a transitional card.
|
||
*/
|
||
export function activeStreamRecoveryPresentation(
|
||
state: XgridsK1State | null | undefined,
|
||
): ActiveStreamRecoveryPresentation | null {
|
||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||
if (!lineage) return null;
|
||
const recovery = lineage.recovery;
|
||
if (recovery.state === "reconnecting") {
|
||
return {
|
||
state: "reconnecting",
|
||
eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||
title: "Восстанавливаем соединение",
|
||
statusLabel: "Восстановление связи",
|
||
tone: "neutral",
|
||
detail: "Проверка прежнего активного контура только для чтения. START, STOP и настройки сети не отправляются.",
|
||
progressLabel: recoveryProgressLabel(recovery),
|
||
showSpinner: true,
|
||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||
};
|
||
}
|
||
if (recovery.state === "blocked") {
|
||
return {
|
||
state: "blocked",
|
||
eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ",
|
||
title: recovery.camera_recovery === "blocked"
|
||
? "Видеопоток не восстановлен"
|
||
: "Связь не восстановлена",
|
||
statusLabel: "Восстановление остановлено",
|
||
tone: "warning",
|
||
detail: recovery.camera_recovery === "blocked"
|
||
? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём."
|
||
: "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.",
|
||
progressLabel: recoveryProgressLabel(recovery),
|
||
showSpinner: false,
|
||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||
};
|
||
}
|
||
if (recovery.state === "standby") {
|
||
return {
|
||
state: "standby",
|
||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||
title: "Устройство перешло в ожидание",
|
||
statusLabel: "Приём завершён",
|
||
tone: "neutral",
|
||
detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.",
|
||
progressLabel: recoveryProgressLabel(recovery),
|
||
showSpinner: false,
|
||
forceFinishAvailable: false,
|
||
};
|
||
}
|
||
if (recovery.state === "fault") {
|
||
return {
|
||
state: "fault",
|
||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||
title: "K1 сообщил об ошибке",
|
||
statusLabel: "Восстановление невозможно",
|
||
tone: "danger",
|
||
detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.",
|
||
progressLabel: recoveryProgressLabel(recovery),
|
||
showSpinner: false,
|
||
forceFinishAvailable: false,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Only an exact, still-active background reconnect may hide the generic red
|
||
* error banner. A failed explicit local finish is operator-facing evidence and
|
||
* must remain visible even while the last accepted snapshot says reconnecting.
|
||
*/
|
||
export function suppressGenericErrorDuringActiveStreamRecovery(
|
||
state: XgridsK1State | null | undefined,
|
||
errorAction?: string | null,
|
||
): boolean {
|
||
if (errorAction === "force-finish") return false;
|
||
return activeStreamRecoveryPresentationAuthority(state) !== null;
|
||
}
|