wip(k1): checkpoint connection recovery rewrite

Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 deletions
@@ -0,0 +1,172 @@
import {
ActivityIndicator,
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import type { ActiveStreamRecoveryPresentation } from "../activeStreamRecovery";
export type ActiveStreamRecoverySurfaceVariant = "panel" | "compact";
export interface ActiveStreamRecoverySurfaceProps {
presentation: ActiveStreamRecoveryPresentation | null;
forceFinishing: boolean;
actionBusy: boolean;
onForceFinish: () => void;
variant?: ActiveStreamRecoverySurfaceVariant;
}
interface ActiveStreamRecoverySurfaceCopy {
eyebrow: string;
title: string;
statusLabel: string;
detail: string;
showSpinner: boolean;
forceFinishAvailable: boolean;
}
function surfaceCopy({
presentation,
forceFinishing,
}: Pick<
ActiveStreamRecoverySurfaceProps,
"presentation" | "forceFinishing"
>): ActiveStreamRecoverySurfaceCopy {
return {
eyebrow: forceFinishing
? "СВЯЗЬ · ЛОКАЛЬНОЕ ЗАВЕРШЕНИЕ"
: presentation?.eyebrow ?? "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
title: forceFinishing
? "Завершаем локальный приём"
: presentation?.title ?? "Восстанавливаем соединение",
statusLabel: forceFinishing
? "Локальное завершение"
: presentation?.statusLabel ?? "Восстановление связи",
detail: forceFinishing
? "Закрываем только локальный приём и сохранение. Команда STOP устройству не отправляется."
: presentation?.detail ?? "Проверяем состояние активного приёма.",
showSpinner: forceFinishing || presentation?.showSpinner === true,
forceFinishAvailable:
!forceFinishing && presentation?.forceFinishAvailable === true,
};
}
function RecoveryState({
presentation,
copy,
}: {
presentation: ActiveStreamRecoveryPresentation | null;
copy: ActiveStreamRecoverySurfaceCopy;
}) {
const stateClassName = copy.showSpinner
? "active-stream-recovery__state"
: "active-stream-recovery__state active-stream-recovery__state--static";
return (
<div className={stateClassName}>
{copy.showSpinner ? <ActivityIndicator size="compact" /> : null}
<div className="active-stream-recovery__copy">
<strong>{copy.title}</strong>
<span>{copy.detail}</span>
{presentation?.progressLabel ? (
<small>{presentation.progressLabel}</small>
) : null}
</div>
</div>
);
}
function RecoveryAction({
visible,
actionBusy,
compact,
onForceFinish,
}: {
visible: boolean;
actionBusy: boolean;
compact: boolean;
onForceFinish: () => void;
}) {
if (!visible) return null;
return (
<div className="active-stream-recovery__actions">
<Button
size={compact ? "compact" : undefined}
variant="secondary"
disabled={actionBusy}
onClick={onForceFinish}
>
Прервать соединение
</Button>
<p>
Завершит только локальный front/back-приём и сохранение. START, STOP,
Bluetooth и настройки устройства не отправляются.
</p>
</div>
);
}
/**
* One shared recovery owner for the connection and spatial workspaces.
*
* The surface never chooses a mutation by itself: its sole callback is the
* explicitly fenced local force-finish action supplied by the K1 controller.
*/
export function ActiveStreamRecoverySurface({
presentation,
forceFinishing,
actionBusy,
onForceFinish,
variant = "panel",
}: ActiveStreamRecoverySurfaceProps) {
const copy = surfaceCopy({ presentation, forceFinishing });
const tone = forceFinishing ? "neutral" : presentation?.tone ?? "neutral";
const content = (
<>
<RecoveryState presentation={presentation} copy={copy} />
<RecoveryAction
visible={copy.forceFinishAvailable}
actionBusy={actionBusy}
compact={variant === "compact"}
onForceFinish={onForceFinish}
/>
</>
);
if (variant === "compact") {
return (
<section
className="xgrids-k1-spatial-controls xgrids-k1-spatial-controls--recovery"
aria-label="Восстановление активной сессии XGRIDS K1"
aria-live="polite"
aria-busy={copy.showSpinner}
data-recovery-state={
forceFinishing ? "force-finishing" : presentation?.state ?? "reconnecting"
}
>
<div className="active-stream-recovery__compact-heading">
<span>{copy.eyebrow}</span>
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
</div>
<div className="active-stream-recovery active-stream-recovery--compact">
{content}
</div>
</section>
);
}
return (
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">{copy.eyebrow}</span>
<h2>{copy.title}</h2>
</div>
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
</header>
<div className="active-stream-recovery" aria-live="polite">
{content}
</div>
</GlassSurface>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Button,
Checker,
GlassSurface,
@@ -12,6 +13,11 @@ import {
} from "@nodedc/ui-react";
import { profileSelectionForConnectionMode } from "../compatibility";
import {
activeStreamForceFinishAuthority,
activeStreamRecoveryPresentation,
exactActiveStreamRecoveryLineage,
} from "../activeStreamRecovery";
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
@@ -22,48 +28,63 @@ import {
} from "../configuration";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
canIssueCanonicalStop,
connectionPolicyAllows,
isConfirmedLiveState,
isSoftwareCommandedAcquisition,
isReleasedTerminalAcquisitionFailure,
currentAppliedConnectionTopology,
isSourceRuntimeBusy,
isVendorWriteCapable,
isTerminalAcquisitionState,
recoverableAcquisition,
requiresCanonicalStopAfterTerminalLocalFailure,
sourceStatusLabel,
} from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName";
import { connectionPolicyOperatorGuidance } from "../presentation";
import {
normalizeProjectName,
projectNameAfterConnectionModeSelection,
shouldHydratePreparedProject,
validateProjectName,
} from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
import type { OperatorPresenceConfirmation } from "../api";
import {
activeStopTarget,
operatorActionPhysicalAcceptance,
preparationTarget,
preparedStartTarget,
} from "../physicalCommandConfirmation";
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "live", label: "Прямой приём" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
export function K1AcquisitionPipeline({
controller,
desiredConnectionMode = "bridge",
openSpatialScene,
activateAutomaticSpatialSource,
}: {
controller: XgridsK1Controller;
desiredConnectionMode?: "bridge" | "quick-connect" | "direct-connect";
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}) {
const {
state,
pendingAction,
physicalStopIntentSpent,
physicalStopInFlight,
closeApplicationControlSession,
startCanonicalAcquisition,
prepareCanonicalAcquisition,
startPreparedAcquisition,
startReplay,
stop,
stopLocalReceiver,
forceFinishActiveStreamLocally,
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@@ -75,13 +96,59 @@ export function K1AcquisitionPipeline({
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
const hydratedAcquisitionId = useRef<string | null>(null);
const previousDesiredConnectionMode = useRef(desiredConnectionMode);
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName);
const vendorWriteCapable = isVendorWriteCapable(state);
const control = state?.application_control_session;
const controlPhase = control?.state ?? "idle";
const appliedTopology = currentAppliedConnectionTopology(state);
const connectionMode = desiredConnectionMode;
const backendDesiredConnectionMode = state?.desired_connection_mode
?? desiredConnectionMode;
const configuredConnectionMode = state?.configured_connection_mode
?? state?.connection_mode
?? null;
const activeConnectionMode = state?.active_connection_mode
?? (appliedTopology?.status === "active" ? appliedTopology.connectionMode : null);
const desiredSelectionCommitted = backendDesiredConnectionMode
=== desiredConnectionMode;
const desiredModeMatchesActive = desiredSelectionCommitted
&& activeConnectionMode === desiredConnectionMode;
const modeSwitchRequired = Boolean(
!desiredSelectionCommitted
|| (activeConnectionMode && !desiredModeMatchesActive)
|| (configuredConnectionMode && configuredConnectionMode !== desiredConnectionMode),
);
const connectionConfigured = Boolean(
appliedTopology?.status === "active"
&& desiredModeMatchesActive
&& state?.connection_lifecycle?.ready_to_start === true,
);
useEffect(() => {
if (previousDesiredConnectionMode.current === desiredConnectionMode) return;
previousDesiredConnectionMode.current = desiredConnectionMode;
const projectNameAfterSelection = projectNameAfterConnectionModeSelection(
preparedAcquisition?.project_name,
);
// A prepared acquisition is immutable backend state, not a draft owned by
// this selector. Preserve its project while the operator previews another
// mode so selecting the active mode again can resume START immediately.
if (preparedAcquisition) {
setProjectName(projectNameAfterSelection);
setProjectNameTouched(false);
return;
}
// Draft project fields belong to the previously selected transport. The
// dropdown sends no physical command; Connect performs the later bounded
// mode transaction, while START remains fenced in the meantime.
setProjectName(projectNameAfterSelection);
setProjectNameTouched(false);
setMountType(SUPPORTED_MOUNT_TYPE);
setGnssMode(SUPPORTED_GNSS_MODE);
}, [desiredConnectionMode, preparedAcquisition]);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -93,11 +160,35 @@ export function K1AcquisitionPipeline({
useEffect(() => {
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
if (!shouldHydratePreparedProject({
acquisitionId,
hydratedAcquisitionId: hydratedAcquisitionId.current,
modeSwitchRequired,
})) return;
hydratedAcquisitionId.current = acquisitionId;
setProjectName(preparedAcquisition?.project_name ?? "");
setProjectNameTouched(false);
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
}, [
modeSwitchRequired,
preparedAcquisition?.acquisition_id,
preparedAcquisition?.project_name,
]);
useEffect(() => {
const acquisition = state?.acquisition;
if (
hydratedAcquisitionId.current === null
|| !acquisition
|| acquisition.acquisition_id !== hydratedAcquisitionId.current
|| !isTerminalAcquisitionState(acquisition.state)
|| state?.source_mode !== "idle"
) return;
hydratedAcquisitionId.current = null;
setProjectName("");
setProjectNameTouched(false);
setMountType(SUPPORTED_MOUNT_TYPE);
setGnssMode(SUPPORTED_GNSS_MODE);
}, [state?.acquisition, state?.source_mode]);
const isBusy = pendingAction !== null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
@@ -108,10 +199,50 @@ export function K1AcquisitionPipeline({
: activeAcquisition
? "live"
: sessionIntent;
const sourceLabel = sourceStatusLabel(state);
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
const gracefulStopTarget = activeStopTarget(state);
const terminalPhysicalStopObserved =
requiresCanonicalStopAfterTerminalLocalFailure(state);
const physicalStopExecutable = Boolean(
gracefulStopTarget
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
);
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
const localReceiverStopExecutable = Boolean(
connectionPolicyAllows(state, "stop-local-receiver")
&& preparedAcquisition === null,
);
const terminalPhysicalStopPending = terminalPhysicalStopObserved
&& physicalStopInFlight;
const recoveredPhysicalStop = terminalPhysicalStopObserved
&& physicalStopExecutable
&& !physicalStopInFlight;
const terminalLocalRecovery = terminalPhysicalStopObserved
&& !physicalStopPresented
&& localReceiverStopExecutable;
const terminalReadOnlyRecovery = terminalPhysicalStopObserved
&& !physicalStopPresented
&& !localReceiverStopExecutable;
const terminalLocalCapturePending = Boolean(
state?.acquisition?.cleanup_pending === true
|| state?.source_mode === "live",
);
const sourceLabel = terminalPhysicalStopPending
? "Команда отправлена"
: recoveredPhysicalStop
? "Требуется остановка"
: terminalLocalRecovery
? "Локальное завершение доступно"
: terminalReadOnlyRecovery
? "Действия заблокированы"
: sourceStatusLabel(state);
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
const relevantAcquisitionFailed = state?.source_mode !== "replay"
&& state?.acquisition?.state === "failed"
&& !releasedAcquisitionFailure;
const sourceTone: StatusTone =
state?.phase === "error" || relevantAcquisitionFailed
terminalPhysicalStopObserved
? "warning"
: (state?.phase === "error" && !releasedAcquisitionFailure) || relevantAcquisitionFailed
? "danger"
: isConfirmedLiveState(state) || state?.source_mode === "replay"
? "success"
@@ -122,42 +253,105 @@ export function K1AcquisitionPipeline({
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked],
);
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
const activeRecoveryForceFinishAuthority = activeStreamForceFinishAuthority(state);
const activeRecoveryLineage = exactActiveStreamRecoveryLineage(state);
const recoveredActiveSession = Boolean(
activeRecoveryLineage?.recovery.state === "recovered"
&& state?.phase === "live"
&& state.source_mode === "live"
&& activeAcquisition?.state === "acquiring"
&& activeRecoveryLineage.acquisitionId === activeAcquisition.acquisition_id,
);
const recoveredActiveSessionLabel = activeAcquisition?.project_name?.trim()
|| activeAcquisition?.acquisition_id
|| "текущая сессия";
const localForceFinishPending = pendingAction === "force-finish";
if (activeRecoveryPresentation || localForceFinishPending) {
return (
<ActiveStreamRecoverySurface
presentation={activeRecoveryPresentation}
forceFinishing={localForceFinishPending}
actionBusy={pendingAction !== null}
onForceFinish={() => {
if (!activeRecoveryForceFinishAuthority) return;
void forceFinishActiveStreamLocally();
}}
/>
);
}
const preparedCanonicalLaunch =
preparedAcquisition?.control_mode === "plugin-commanded";
const launchBlockedByAcquisition =
activeAcquisition !== null && !preparedCanonicalLaunch;
const controlRetryBlocked =
controlPhase === "failed" && control?.can_open !== true;
const finalStartTarget = preparedStartTarget(state);
const draftPreparationTarget = preparationTarget(
state,
projectNameValidation.value,
);
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
? connectionPolicyOperatorGuidance(state, "start-acquisition")
: null;
const physicalStopGuidance = gracefulStopTarget
&& !physicalStopPresented
&& !terminalPhysicalStopObserved
? connectionPolicyOperatorGuidance(state, "stop-acquisition")
: null;
const physicalStopGuidanceCopy = terminalPhysicalStopPending
? "Команда остановки устройства уже отправлена. Ждём новое подтверждённое состояние; повторная команда не отправляется."
: terminalLocalRecovery
? physicalStopIntentSpent
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
: terminalReadOnlyRecovery
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
: physicalStopGuidance
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
: gracefulStopTarget && !physicalStopPresented
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
: null;
const startLive = async () => {
const submitFinalStart = async () => {
const physicalAcceptance = operatorActionPhysicalAcceptance();
await runAutomaticSpatialSourceStart(
() => startPreparedAcquisition(physicalAcceptance),
activateAutomaticSpatialSource,
openSpatialScene,
);
};
const requestLivePreparation = async () => {
setProjectNameTouched(true);
if (
!state?.k1_ip ||
!connectionConfigured ||
!connectionMode ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
controlRetryBlocked ||
projectNameValidation.error
) return;
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
await runAutomaticSpatialSourceStart(
() => startCanonicalAcquisition({
control: {
...PHYSICAL_ACCEPTANCE,
timezone_name: timezoneName,
},
acquisition: {
project_name: projectNameValidation.value,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(
state.connection_mode ?? "bridge",
),
},
physicalAcceptance: PHYSICAL_ACCEPTANCE,
}),
activateAutomaticSpatialSource,
openSpatialScene,
);
if (finalStartTarget) {
if (!physicalStartAllowed) return;
await submitFinalStart();
return;
}
if (!draftPreparationTarget) return;
const prepared = await prepareCanonicalAcquisition({
acquisition: {
project_name: projectNameValidation.value,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
},
});
if (!prepared) return;
await submitFinalStart();
};
const submitReplay = async () => {
@@ -177,8 +371,8 @@ export function K1AcquisitionPipeline({
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
<span className="section-eyebrow">{terminalPhysicalStopPending ? "ВОССТАНОВЛЕНИЕ · КОМАНДА ОТПРАВЛЕНА" : recoveredPhysicalStop ? "ВОССТАНОВЛЕНИЕ · ОСТАНОВКА" : terminalLocalRecovery ? "ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР" : terminalReadOnlyRecovery ? "ВОССТАНОВЛЕНИЕ · ТОЛЬКО ЧТЕНИЕ" : recoveredActiveSession ? "СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ" : effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{terminalPhysicalStopPending ? "Ожидаем подтверждение устройства" : recoveredPhysicalStop ? "Сканирование продолжается" : terminalLocalRecovery ? "Завершите локальный приём" : terminalReadOnlyRecovery ? "Ожидайте подтверждённое состояние" : recoveredActiveSession ? "Связь восстановлена · приём продолжается" : effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
</header>
@@ -188,7 +382,55 @@ export function K1AcquisitionPipeline({
items={selectableSessionItems}
onChange={(intent) => { if (!sessionLocked) setSessionIntent(intent); }}
/>
{effectiveSessionIntent === "live" ? (
{terminalPhysicalStopObserved ? (
<div className="session-form">
<div className="connection-summary">
{terminalPhysicalStopPending ? (
<>
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
<strong>Команда остановки устройства уже отправлена</strong>
<small>
Ждём новое подтверждённое состояние K1. Повторная команда устройству не отправляется.
</small>
</>
) : recoveredPhysicalStop ? (
<>
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
<strong>Сканирование подтверждено; требуется явный STOP</strong>
<small>
Нажмите «Остановить сканирование» ниже или в пространственной сцене. Новый проект, START и настройка сети останутся заблокированы до подтверждённого READY.
</small>
</>
) : terminalLocalRecovery ? (
<>
<span>Команды устройству заблокированы</span>
<strong>Доступно локальное завершение приёма</strong>
<small>
Повторная команда K1 не отправляется. Завершите локальный приём или выполните read-only восстановление.
</small>
</>
) : (
<>
<span>Управляющие действия заблокированы</span>
<strong>Доступно только read-only восстановление</strong>
<small>
Дождитесь нового подтверждённого состояния; локальные и управляющие команды сейчас не разрешены.
</small>
</>
)}
</div>
</div>
) : recoveredActiveSession ? (
<div className="session-form">
<div className="connection-summary">
<span>Исходная сессия · {recoveredActiveSessionLabel}</span>
<strong>Продолжаем тот же приём без нового START</strong>
<small>
Автоматическое восстановление не отправляло START, STOP, Bluetooth или настройки сети. Явная остановка ниже доступна только при текущем подтверждённом праве на STOP.
</small>
</div>
</div>
) : effectiveSessionIntent === "live" ? (
<div className="session-form">
<div className="scan-configuration-grid">
<div className="configuration-field">
@@ -228,40 +470,53 @@ export function K1AcquisitionPipeline({
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
: "Имя отправляется только при START; отдельной команды сохранения нет."}
placeholder="Например, TEST001"
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
aria-busy={pendingAction === "live"}
icon={pendingAction === "live"
? <ActivityIndicator size="compact" />
: <Icon name="activity" />}
disabled={
isBusy ||
!state?.k1_ip ||
!connectionConfigured ||
projectNameValidation.error !== null ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
controlRetryBlocked
controlRetryBlocked ||
modeSwitchRequired ||
Boolean(finalStartTarget && !physicalStartAllowed)
}
onClick={() => void startLive()}
onClick={() => void requestLivePreparation()}
>
{pendingAction === "live"
? controlPhase === "connecting"
? "Синхронизация с K1…"
? "Синхронизация…"
: controlPhase === "workspace-requested"
? "Входим в рабочее пространство…"
: controlPhase === "project-requested"
? "Готовим проект и локальный приём…"
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования…"
: "Запускаем K1 и локальный приём…"
: preparedCanonicalLaunch
? "Продолжить запуск сканирования и приёма"
: "Запустить сканирование и локальный приём"}
? "Запускаем приём…"
: "Подготавливаем проект и локальный приём…"
: finalStartTarget
? "Запустить приём"
: preparedCanonicalLaunch
? "Продолжить запуск"
: "Запустить приём"}
</Button>
<p className="start-confirmation-note">
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
{modeSwitchRequired
? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
: !connectionConfigured
? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
: physicalStartGuidance
? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
: "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
</p>
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
<Button
variant="ghost"
disabled={isBusy}
@@ -272,23 +527,23 @@ export function K1AcquisitionPipeline({
) : null}
<p className="live-instruction">
{controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
: controlPhase === "connecting"
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
: controlPhase === "workspace-requested"
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
: "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField label="Путь к записи" hint="Локальный файл исходных данных" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
<TextField label="Путь к записи" hint="Локальный файл записи" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
<TextField label="Скорость повтора" hint="Множитель" type="number" min="0.1" step="0.1" value={replaySpeed} onChange={(event) => setReplaySpeed(event.target.value)} />
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
@@ -301,25 +556,47 @@ export function K1AcquisitionPipeline({
)}
<div className="session-footer">
<p>
{state?.source_mode === "replay"
{physicalStopGuidanceCopy
? physicalStopGuidanceCopy
: recoveredPhysicalStop && physicalStopExecutable
? terminalLocalCapturePending
? "Локальный приём ещё требует завершения. Эта кнопка отправит ровно один явный STOP и дождётся подтверждённого результата."
: "Локальная запись уже остановлена. Эта кнопка отправит ровно один явный STOP и дождётся READY."
: state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
? physicalStopPresented
? "Остановка отправит профилированную команду и дождётся завершения локального сохранения."
: localReceiverStopExecutable
? "Остановка завершает только локальный приём и сохранение. Состояние сканирования остаётся неизвестным."
: "Действие остановки сейчас не разрешено. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
: "Активного источника сейчас нет."}
</p>
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop(
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
)}
>
{pendingAction === "stop"
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
</Button>
{physicalStopPresented || localReceiverStopExecutable ? (
<Button
variant="secondary"
disabled={
isBusy
|| (physicalStopPresented && !physicalStopExecutable)
|| (!sourceRuntimeBusy && preparedAcquisition !== null)
}
onClick={() => {
if (physicalStopExecutable) {
void stop(operatorActionPhysicalAcceptance());
return;
}
if (localReceiverStopExecutable) {
void stopLocalReceiver();
}
}}
>
{physicalStopInFlight
? "Останавливаем устройство…"
: pendingAction === "stop"
? physicalStopPresented ? "Останавливаем устройство…" : state?.source_mode === "replay" ? "Останавливаем повтор…" : "Завершаем локальный приём…"
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
</Button>
) : null}
{activeAcquisition ? (
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
@@ -8,7 +8,11 @@ import {
formatNumber,
pipelineLatency,
} from "../presentation";
import { isConfirmedLiveState } from "../lifecycle";
import {
activeConnectionEndpointLabel,
backendConnectionTopology,
isConfirmedLiveState,
} from "../lifecycle";
import type { XgridsK1Controller } from "../runtimeContext";
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
@@ -33,6 +37,18 @@ export function K1Diagnostics({ controller, sourceLabel }: {
const { state, backendStatus, eventStatus, latencyHistory } = controller;
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
const latency = pipelineLatency(streamActive ? state?.metrics : undefined);
const activeEndpoint = activeConnectionEndpointLabel(state);
const topology = backendConnectionTopology(state);
const unverifiedEndpoint = topology?.status !== "active" ? topology?.endpoint : null;
const endpointLabel = activeEndpoint
? "Адрес подключения"
: topology?.source === "durable"
? "Адрес конфигурации"
: topology?.source === "last-known"
? "Адрес конфигурации"
: topology?.source === "applied"
? "Адрес конфигурации"
: "Адрес подключения";
return (
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
@@ -43,7 +59,20 @@ export function K1Diagnostics({ controller, sourceLabel }: {
<dl className="detail-list">
<DetailRow label="Канал событий"><span className="inline-state" data-state={eventStatus}>{eventStatusLabel(eventStatus)}</span></DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства"><code>{state?.k1_ip || "Не получен"}</code></DetailRow>
<DetailRow label={endpointLabel}>
{activeEndpoint
? <code>{activeEndpoint}</code>
: unverifiedEndpoint
? (
<span>
<code>{unverifiedEndpoint}</code>
{topology?.status === "configured-unverified"
? " · подключение ещё не подтверждено"
: " · связь не подтверждена"}
</span>
)
: <span>Не получен</span>}
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
@@ -1,12 +1,14 @@
import { isConfirmedLiveState } from "../lifecycle";
import { hasAuthoritativeData, isConfirmedLiveState } from "../lifecycle";
import { finiteMetric, formatNumber, pipelineLatency } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext";
import { MetricCard } from "./MetricCard";
export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
const { state } = controller;
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const streamAuthoritative = state?.source_mode === "replay" || Boolean(
isConfirmedLiveState(state) && hasAuthoritativeData(state),
);
const metrics = streamAuthoritative ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
@@ -35,7 +37,7 @@ export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
detail="Данные потока при этом сохраняются"
/>
</section>
);
@@ -0,0 +1,225 @@
import { useState, type ReactNode } from "react";
import { Button } from "@nodedc/ui-react";
import type { XgridsConnectionAttempt } from "../api";
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
const connectionAttemptStageLabels: Record<string, string> = {
accepted: "Запрос принят",
"scan-selection-admitted": "Результат выбран",
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
"device-ap-activation": "Подготовка локальной сети",
"ble-provisioning-write": "Передаются настройки сети",
"ble-write-dispatched": "Настройки переданы",
"status-observing": "Ожидание ответа",
"device-topology-applied": "Целевая сеть подтверждена",
"host-wifi-association": "Настройка связи с сетью",
"control-endpoint-admission": "Подготовка управляющего канала",
connected: "Связь подтверждена",
"network-configured": "Сеть настроена",
};
function attemptStageLabel(attempt: XgridsConnectionAttempt): string {
const normalized = attempt.stage.replace(/-failed$/, "");
return connectionAttemptStageLabels[normalized] ?? "Подключение остановлено";
}
function attemptSideEffectLabel(value: string): string {
if (value === "none") return "Команда не отправлялась";
if (value === "applied") return "Целевая сеть подтверждена";
if (value === "confirmed") return "Передача команды подтверждена";
return "Результат команды не подтверждён";
}
export function attemptNetworkPhaseLabel(
value: XgridsConnectionAttempt["phase"],
): string {
const phase = String(value);
if (phase === "network_applied") return "Настройки сети применены";
if (phase === "network_outcome_unknown") {
return "Результат применения настроек сети не подтверждён";
}
return "Настройки сети не применены";
}
function attemptControlStateLabel(
value: XgridsConnectionAttempt["control_state"],
): string {
if (value === "ready") return "Управляющее подключение подтверждено";
if (value === "control_not_ready") return "Управляющее подключение не подтверждено";
return "Состояние управляющего подключения неизвестно";
}
export function attemptNextActionLabel(
value: XgridsConnectionAttempt["safe_next_action"],
): string {
switch (value) {
case "wait-for-current-attempt":
return "Дождаться завершения текущей попытки";
case "continue-with-control-verification":
return "Продолжить текущее подключение";
case "verify-control-read-only":
return "Проверить управление без изменения сети";
case "start-acquisition":
return "Готово к запуску приёма";
case "stop-local-receiver":
return "Завершить только локальный приём";
case "retire-unavailable-physical-target":
return "Исключить недоступный прежний K1 и выбрать другой";
case "scan-select-connect":
return "Выполнить новый поиск и выбрать результат";
case "manual-recovery-required":
return "Требуется ручное восстановление";
}
}
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
"network-provision-discovery-generation-conflict":
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
"connection-mode-draft-revision-conflict":
"Способ подключения изменился до запуска операции. Настройки устройства не изменялись; повторите явное действие.",
"connection-mode-draft-mismatch":
"Выбранный способ подключения ещё не подтверждён локальным контуром. Настройки устройства не изменялись.",
"physical-command-reconciliation-required":
"Сначала завершите отдельную проверку физического состояния K1 без изменений устройства. Новая команда не отправлялась.",
"physical-device-already-active":
"K1 всё ещё подтверждён в активном сканировании. Сначала выполните явную остановку; новая сетевая команда не отправлялась.",
};
function publicConnectionErrorLabel(
attempt: XgridsConnectionAttempt | null | undefined,
structured: ReturnType<typeof hostFailureDiagnosticPresentation>,
): string {
const publicCode = attempt?.public_error_code?.trim();
if (publicCode && publicConnectionErrorLabels[publicCode]) {
return publicConnectionErrorLabels[publicCode];
}
return structured
? "Системный контур безопасно остановил операцию. Автоматического повтора не было."
: "Подключение не завершено. Автоматического повтора не было.";
}
export function K1OperatorError({
diagnostic,
attempt,
title = "Локальная операция завершилась ошибкой",
recoveryActions,
compact = false,
showDefaultActions = true,
onRefresh,
onClear,
}: {
/** Kept for call-site compatibility; unreviewed exception text is never rendered. */
message?: string;
diagnostic?: unknown;
attempt?: XgridsConnectionAttempt | null;
title?: string;
recoveryActions?: ReactNode;
compact?: boolean;
showDefaultActions?: boolean;
onRefresh: () => void;
onClear: () => void;
}) {
const structured = hostFailureDiagnosticPresentation(diagnostic);
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
const copyDiagnosticBundle = async () => {
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
await navigator.clipboard.writeText(
JSON.stringify(attempt.diagnostic_bundle, null, 2),
);
setDiagnosticCopied(true);
};
const hasDetails = Boolean(structured || attempt);
return (
<aside
className={`error-banner${compact ? " error-banner--compact" : ""}`}
role="alert"
>
<span className="error-banner__dot" aria-hidden="true" />
<div className="error-banner__copy">
<strong>{title}</strong>
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
{recoveryActions ? (
<div className="error-banner__recovery-actions">
{recoveryActions}
</div>
) : null}
{hasDetails ? (
<details className="error-banner__details">
<summary>Подробности и диагностика</summary>
{structured ? (
<dl className="error-banner__diagnostic">
<div>
<dt>Причина</dt>
<dd>{structured.codeLabel}</dd>
</div>
<div>
<dt>Системный контур</dt>
<dd>{structured.domainLabel}</dd>
</div>
<div>
<dt>Влияние</dt>
<dd>{structured.impactLabel}</dd>
</div>
<div>
<dt>Что сделать</dt>
<dd>{structured.operatorActionLabel}</dd>
</div>
</dl>
) : null}
{attempt ? (
<dl
className="error-banner__diagnostic"
aria-label="Диагностика подключения"
>
<div>
<dt>Попытка</dt>
<dd><code>{attempt.attempt_id}</code></dd>
</div>
<div>
<dt>Остановлено на шаге</dt>
<dd>{attemptStageLabel(attempt)}</dd>
</div>
<div>
<dt>Что изменилось</dt>
<dd>{attemptSideEffectLabel(attempt.side_effect_status)}</dd>
</div>
<div>
<dt>Сеть</dt>
<dd>{attemptNetworkPhaseLabel(attempt.phase)}</dd>
</div>
<div>
<dt>Управление</dt>
<dd>{attemptControlStateLabel(attempt.control_state)}</dd>
</div>
<div>
<dt>Безопасное действие</dt>
<dd>{attemptNextActionLabel(attempt.safe_next_action)}</dd>
</div>
</dl>
) : null}
</details>
) : null}
</div>
{attempt?.diagnostic_bundle || showDefaultActions ? (
<div className="error-banner__actions">
{attempt?.diagnostic_bundle ? (
<Button size="compact" variant="secondary" onClick={() => void copyDiagnosticBundle()}>
{diagnosticCopied ? "Диагностика скопирована" : "Скопировать диагностику"}
</Button>
) : null}
{showDefaultActions ? (
<>
<Button size="compact" variant="secondary" onClick={onRefresh}>
Проверить состояние
</Button>
<Button size="compact" variant="ghost" onClick={onClear}>
Закрыть
</Button>
</>
) : null}
</div>
) : null}
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,22 @@
import { Button } from "@nodedc/ui-react";
import { ActivityIndicator, Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type {
AcquisitionState,
OperatorPresenceConfirmation,
XgridsAcquisition,
XgridsK1State,
} from "../api";
import {
activeStreamForceFinishAuthority,
activeStreamRecoveryPresentation,
} from "../activeStreamRecovery";
import {
canIssueCanonicalStop,
connectionPolicyAllows,
hasAuthoritativeData,
hasControlAuthority,
isSoftwareCommandedAcquisition,
requiresCanonicalStopAfterTerminalLocalFailure,
shouldRenderSpatialControls,
} from "../lifecycle";
import {
@@ -15,7 +24,15 @@ import {
formatNumber,
spatialActionFailure,
} from "../presentation";
import { useXgridsK1Controller } from "../runtimeContext";
import {
activeStopTarget,
operatorActionPhysicalAcceptance,
} from "../physicalCommandConfirmation";
import {
useXgridsK1Controller,
type XgridsK1Controller,
} from "../runtimeContext";
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
interface PhasePresentation {
label: string;
@@ -23,15 +40,31 @@ interface PhasePresentation {
busy: boolean;
}
const PHYSICAL_ACCEPTANCE = {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
} satisfies OperatorPresenceConfirmation;
export interface K1SpatialAuthorityState {
controlAuthoritative: boolean;
dataAuthoritative: boolean;
softwareCommanded: boolean;
authorityFailure: string | null;
}
function phasePresentation(
export function k1SpatialAuthorityState(
state: XgridsK1State | null | undefined,
): K1SpatialAuthorityState {
const controlAuthoritative = hasControlAuthority(state);
const dataAuthoritative = hasAuthoritativeData(state);
return {
controlAuthoritative,
dataAuthoritative,
softwareCommanded: controlAuthoritative && isSoftwareCommandedAcquisition(state),
authorityFailure: state?.acquisition?.state === "acquiring" && !dataAuthoritative
? controlAuthoritative
? "Поток данных K1 не подтверждён supervisor-ом. Телеметрия скрыта до восстановления data authority."
: "Управляющая сессия K1 потеряна. Локальное завершение доступно, но команды устройству запрещены."
: null,
};
}
export function k1SpatialPhasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
): PhasePresentation {
@@ -49,16 +82,20 @@ function phasePresentation(
busy: false,
},
awaiting_external_start: {
label: "Ожидание запуска на устройстве",
detail: "Запустите сканирование физической кнопкой K1.",
label: softwareCommanded
? "K1 калибруется и готовит облако точек"
: "Ожидание запуска на устройстве",
detail: softwareCommanded
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
? "Калибровка оборудования"
? "K1 калибруется и готовит облако точек"
: "Подготовка локального приёмника",
detail: softwareCommanded
? "Статическая инициализация после запуска — не перемещайте устройство."
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
@@ -106,43 +143,146 @@ function formatDuration(seconds: number): string {
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller;
export function runSpatialActiveStreamForceFinish(
controller: Pick<
XgridsK1Controller,
"state" | "forceFinishActiveStreamLocally"
>,
): Promise<boolean> {
if (!activeStreamForceFinishAuthority(controller.state)) {
return Promise.resolve(false);
}
return controller.forceFinishActiveStreamLocally();
}
export function K1SpatialControlsView({
controller,
}: {
controller: XgridsK1Controller;
}) {
const {
state,
pendingAction,
physicalStopIntentSpent,
physicalStopInFlight,
stop,
stopLocalReceiver,
forceFinishActiveStreamLocally,
} = controller;
const acquisition = state?.acquisition;
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
const localForceFinishPending = pendingAction === "force-finish";
if (activeRecoveryPresentation || localForceFinishPending) {
return (
<ActiveStreamRecoverySurface
presentation={activeRecoveryPresentation}
forceFinishing={localForceFinishPending}
actionBusy={pendingAction !== null}
variant="compact"
onForceFinish={() => {
void runSpatialActiveStreamForceFinish({
state,
forceFinishActiveStreamLocally,
});
}}
/>
);
}
const physicalStopTarget = activeStopTarget(state);
const localReceiverStopAllowed = connectionPolicyAllows(state, "stop-local-receiver");
const physicalStopExecutable = Boolean(
physicalStopTarget
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
);
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
acquisition?.state ?? "",
);
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
return null;
}
const {
controlAuthoritative,
dataAuthoritative,
authorityFailure,
} = k1SpatialAuthorityState(state);
const softwareCommanded = isSoftwareCommandedAcquisition(state);
const phase = phasePresentation(acquisition, softwareCommanded);
const telemetry = deviceTelemetry(state.metrics);
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
acquisition.state,
);
const stopDisabled = pendingAction !== null || stopping;
const dataPlaneState = state?.connection_supervisor?.observed.data_plane.state;
const terminalPhysicalStopRequired = requiresCanonicalStopAfterTerminalLocalFailure(state);
const phase = physicalStopInFlight
? {
label: "Команда остановки устройства отправлена",
detail: "Ждём подтверждённое состояние K1; повторная команда не отправляется.",
busy: true,
}
: terminalPhysicalStopRequired && physicalStopExecutable
? {
label: "Локальный приём остановился · K1 продолжает работу",
detail: "Остановите устройство явной командой; новый START заблокирован.",
busy: false,
}
: terminalPhysicalStopRequired && localReceiverStopAllowed
? {
label: "Состояние K1 требует безопасного восстановления",
detail: "Команда устройству не отправляется. Доступно разрешённое сервером локальное завершение или read-only восстановление.",
busy: false,
}
: terminalPhysicalStopRequired
? {
label: "Управляющие действия заблокированы",
detail: "Дождитесь подтверждённого состояния или выполните read-only восстановление.",
busy: false,
}
: acquisition.state === "acquiring"
&& !dataAuthoritative
? {
label: !controlAuthoritative
? "Управляющая сессия K1 потеряна"
: dataPlaneState === "lost"
? "Связь с потоком K1 потеряна"
: dataPlaneState === "stalled"
? "Поток K1 нестабилен"
: "Ожидаем подтверждённый поток K1",
detail: !controlAuthoritative
? "Состояние acquisition сохранено как последнее известное; команды устройству не отправляются."
: "Управляющая сессия подтверждена, но живые данные пока не получили авторитетный статус.",
busy: false,
}
: k1SpatialPhasePresentation(acquisition, softwareCommanded);
const telemetry = deviceTelemetry(dataAuthoritative ? state.metrics : undefined);
const stopDisabled = pendingAction !== null
|| stopping;
const controlFailure =
state.application_control_session?.state === "failed"
? state.application_control_session.failure?.message ||
"Канонический диалог остановлен; автоматический повтор запрещён."
: null;
const actionFailure = spatialActionFailure(
const runtimeActionFailure = spatialActionFailure(
controller.error ??
controlFailure ??
(cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку."
? physicalStopInFlight
? "Локальный поток или архив ещё не завершён. Команда устройству уже отправлена; дождитесь подтверждённого состояния."
: localReceiverStopAllowed
? "Локальный поток или архив ещё не завершён. Завершите только разрешённый сервером локальный приём."
: "Локальный поток или архив ещё не завершён. Дождитесь подтверждённого состояния или выполните read-only восстановление."
: null),
);
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
return (
<section
className="xgrids-k1-spatial-controls"
aria-label="Управление сессией XGRIDS K1"
aria-busy={phase.busy}
data-busy={phase.busy ? "true" : undefined}
>
<div className="xgrids-k1-spatial-controls__phase">
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : null}
{phase.busy ? <ActivityIndicator size="compact" /> : null}
<span>
<strong>{phase.label}</strong>
<small>{phase.detail}</small>
@@ -165,20 +305,47 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
<small>{actionFailure.detail}</small>
</div>
) : null}
<Button
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
: stopping
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
: actionFailure
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{physicalStopPresented ? (
<Button
size="compact"
variant="secondary"
disabled={stopDisabled || !physicalStopExecutable}
onClick={() => {
if (physicalStopExecutable) {
void stop(operatorActionPhysicalAcceptance());
}
}}
>
<span className="xgrids-k1-spatial-controls__action-label">
{physicalStopInFlight
? "Останавливаем устройство…"
: pendingAction === "stop"
? terminalPhysicalStopRequired ? "Останавливаем K1…" : softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
: stopping
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
: terminalPhysicalStopRequired ? "Остановить K1" : "Остановить устройство и запись"}
</span>
</Button>
) : null}
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
<Button
size="compact"
variant="ghost"
disabled={pendingAction !== null}
onClick={() => void stopLocalReceiver()}
>
<span className="xgrids-k1-spatial-controls__action-label xgrids-k1-spatial-controls__action-label--local">
{pendingAction === "stop"
? "Завершаем локальный приём…"
: "Завершить локальный приём"}
</span>
</Button>
) : null}
</section>
);
}
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
return <K1SpatialControlsView controller={controller} />;
}