chore(k1): checkpoint connection recovery work
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
requiresReadOnlyPhysicalRecovery,
|
||||
savedBridgeRequiresNetworkSetup,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { phaseLabel, phaseTone } from "./presentation";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "./runtimeContext";
|
||||
import type { PendingAction } from "./useXgridsK1Runtime";
|
||||
import type { XgridsK1State } from "./api";
|
||||
import {
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
@@ -59,6 +61,9 @@ export function physicalRecoveryConnectionDetail(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
if (!requiresReadOnlyPhysicalRecovery(state)) return null;
|
||||
if (savedBridgeRequiresNetworkSetup(state)) {
|
||||
return "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Настройки устройства не изменялись; подключите K1 к общей сети заново.";
|
||||
}
|
||||
const retirementAvailable = Boolean(
|
||||
unavailablePhysicalRetirementAuthority(state),
|
||||
);
|
||||
@@ -91,9 +96,11 @@ function connectionPhaseFallbackLabel(phase: string | null | undefined): string
|
||||
*/
|
||||
export function shouldRenderK1OperationalPanels(
|
||||
state: XgridsK1State | null | undefined,
|
||||
pendingAction: PendingAction | null = null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
hasControlAuthority(state)
|
||||
pendingAction === "live"
|
||||
|| hasControlAuthority(state)
|
||||
|| state?.source_mode === "live"
|
||||
|| state?.source_mode === "replay"
|
||||
|| recoverableAcquisition(state)
|
||||
@@ -167,11 +174,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const livePreparationPending = controller.pendingAction === "live";
|
||||
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
|
||||
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
|
||||
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
|
||||
const recoveredPhysicalScanning = physicalRecoveryRequired
|
||||
&& state?.application_control_session?.state === "scanning"
|
||||
@@ -265,7 +274,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
|
||||
const connectionPhaseLabel = livePreparationPending
|
||||
? "Подготовка приёма"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceLabel
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.title
|
||||
@@ -274,7 +285,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: recoveredPhysicalScanning
|
||||
? "Сканирование продолжается"
|
||||
: physicalRecoveryRequired
|
||||
? "Требуется действие"
|
||||
? savedBridgeNetworkSetupRequired
|
||||
? "Нужна настройка сети"
|
||||
: "Требуется действие"
|
||||
: projectedPhase === "error"
|
||||
? connectionPhaseFallbackLabel(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
@@ -288,7 +301,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: connectionTopology?.source === "last-known"
|
||||
? "Подключение отсутствует"
|
||||
: connectionPhaseFallbackLabel(projectedPhase);
|
||||
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition
|
||||
const connectionPhaseTone = livePreparationPending
|
||||
? "warning"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceTone
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
@@ -301,7 +316,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "neutral"
|
||||
: "neutral";
|
||||
const connectionPhaseDetail = physicalStopRecoverySettling
|
||||
const connectionPhaseDetail = livePreparationPending
|
||||
? "Подготовка продолжается."
|
||||
: physicalStopRecoverySettling
|
||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.detail
|
||||
@@ -315,7 +332,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
? "Готово к новой сессии."
|
||||
: "Ожидается состояние локального контура.";
|
||||
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
|
||||
const operationalPanelsVisible = shouldRenderK1OperationalPanels(
|
||||
state,
|
||||
controller.pendingAction,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="device-workspace xgrids-k1-plugin">
|
||||
|
||||
@@ -293,9 +293,6 @@ export function K1AcquisitionPipeline({
|
||||
projectNameValidation.value,
|
||||
);
|
||||
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
|
||||
const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
|
||||
? connectionPolicyOperatorGuidance(state, "start-acquisition")
|
||||
: null;
|
||||
const physicalStopGuidance = gracefulStopTarget
|
||||
&& !physicalStopPresented
|
||||
&& !terminalPhysicalStopObserved
|
||||
@@ -317,8 +314,9 @@ export function K1AcquisitionPipeline({
|
||||
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
|
||||
: null;
|
||||
|
||||
const submitFinalStart = async () => {
|
||||
const physicalAcceptance = operatorActionPhysicalAcceptance();
|
||||
const submitFinalStart = async (
|
||||
physicalAcceptance = operatorActionPhysicalAcceptance(),
|
||||
) => {
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startPreparedAcquisition(physicalAcceptance),
|
||||
activateAutomaticSpatialSource,
|
||||
@@ -342,6 +340,7 @@ export function K1AcquisitionPipeline({
|
||||
return;
|
||||
}
|
||||
if (!draftPreparationTarget) return;
|
||||
const physicalAcceptance = operatorActionPhysicalAcceptance();
|
||||
const prepared = await prepareCanonicalAcquisition({
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
@@ -349,9 +348,10 @@ export function K1AcquisitionPipeline({
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
},
|
||||
physicalAcceptance,
|
||||
});
|
||||
if (!prepared) return;
|
||||
await submitFinalStart();
|
||||
await submitFinalStart(physicalAcceptance);
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
@@ -458,7 +458,6 @@ export function K1AcquisitionPipeline({
|
||||
</div>
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Имя войдёт в единственный канонический START"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
@@ -470,7 +469,7 @@ export function K1AcquisitionPipeline({
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Имя отправляется только при START; отдельной команды сохранения нет."}
|
||||
: undefined}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Button
|
||||
@@ -507,15 +506,6 @@ export function K1AcquisitionPipeline({
|
||||
? "Продолжить запуск"
|
||||
: "Запустить приём"}
|
||||
</Button>
|
||||
<p className="start-confirmation-note">
|
||||
{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 && !recoveredPhysicalStop && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -525,21 +515,6 @@ export function K1AcquisitionPipeline({
|
||||
Отменить запуск до START
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
|
||||
: controlPhase === "workspace-requested"
|
||||
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
|
||||
: controlPhase === "project-requested"
|
||||
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
requiresReadOnlyPhysicalRecovery,
|
||||
reopenedPhysicalReconciliationMatches,
|
||||
retiredPhysicalReopenAuthority,
|
||||
savedBridgeRequiresNetworkSetup,
|
||||
serverBoundAppliedNetworkObservationTarget,
|
||||
transportRefEquivalenceKey,
|
||||
trustedConnectionBinding,
|
||||
@@ -358,12 +359,11 @@ export function connectionAttemptOwnsAppliedNetworkRecovery(
|
||||
|| attempt.phase !== "network_applied"
|
||||
|| attempt.control_state === "ready"
|
||||
) return false;
|
||||
if (["accepted", "running"].includes(attempt.status)) return true;
|
||||
return [
|
||||
"continue-with-control-verification",
|
||||
"verify-control-read-only",
|
||||
"manual-recovery-required",
|
||||
].includes(attempt.safe_next_action);
|
||||
// Only an operation that is still executing owns the screen. A terminal
|
||||
// network-applied record is audit history: the backend may continue to use
|
||||
// it as a command fence, but it must never replace the operator's two normal
|
||||
// choices (verify the saved K1 or start a fresh Bluetooth connection).
|
||||
return ["accepted", "running"].includes(attempt.status);
|
||||
}
|
||||
|
||||
export async function dispatchUnavailablePhysicalRetirementForCurrentRuntime<T>(
|
||||
@@ -1537,6 +1537,7 @@ export function K1ProvisioningPipeline({
|
||||
typeof isSnapshotRuntimeCurrent !== "function"
|
||||
|| runtimeActionIsCurrent(currentRuntimeActionFence)
|
||||
) ? pendingAction : null;
|
||||
const livePreparationPending = pendingAction === "live";
|
||||
const reconfiguration = activeConnectionReconfiguration(state);
|
||||
const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
|
||||
const reconfigurationIntentId = reconfiguration?.intent_id ?? null;
|
||||
@@ -1660,7 +1661,8 @@ export function K1ProvisioningPipeline({
|
||||
const physicalRecoveryVerificationPending =
|
||||
currentReadOnlyReconnectPresentation?.kind === "physical";
|
||||
const provisioningMutationBusy = Boolean(
|
||||
presentedPendingAction
|
||||
livePreparationPending
|
||||
|| presentedPendingAction
|
||||
|| modeResetPending !== null
|
||||
|| currentPreparingReconfigurationRequest
|
||||
|| searchDisplayActive
|
||||
@@ -1731,6 +1733,11 @@ export function K1ProvisioningPipeline({
|
||||
const appliedNetworkAttempt = state?.connection_attempt?.phase === "network_applied"
|
||||
? state.connection_attempt
|
||||
: null;
|
||||
const terminalAppliedRecoveryAttempt = appliedNetworkAttempt
|
||||
&& appliedNetworkAttempt.control_state !== "ready"
|
||||
&& !["accepted", "running"].includes(appliedNetworkAttempt.status)
|
||||
? appliedNetworkAttempt
|
||||
: null;
|
||||
const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
|
||||
errorCorrelation,
|
||||
state,
|
||||
@@ -1741,9 +1748,11 @@ export function K1ProvisioningPipeline({
|
||||
? state.connection_attempt
|
||||
: null;
|
||||
const connectionRecoveryAttempt = correlatedConnectionAttempt
|
||||
?? unknownNetworkOutcomeAttempt;
|
||||
?? unknownNetworkOutcomeAttempt
|
||||
?? terminalAppliedRecoveryAttempt;
|
||||
const connectionRecoveryObservationAllowed = Boolean(
|
||||
!connectionRecoveryAttempt
|
||||
|| connectionRecoveryAttempt.phase === "network_applied"
|
||||
|| [
|
||||
"continue-with-control-verification",
|
||||
"verify-control-read-only",
|
||||
@@ -1780,15 +1789,14 @@ export function K1ProvisioningPipeline({
|
||||
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
|
||||
const networkRecoveryRequired = unresolvedAppliedAttempt !== null
|
||||
&& !physicalRecoveryRequired;
|
||||
// Scan keeps the reset-owned clean draft intact, while the first later
|
||||
// Connect/Verify operation releases it. This hides only pre-reset saved
|
||||
// history and never masks a new post-reset failure.
|
||||
// A settled reset owns the clean new-device draft, but it must not erase the
|
||||
// separately durable fast-reconnect offer. The operator can either verify
|
||||
// the last confirmed K1 without writes or start a fresh Bluetooth search.
|
||||
const scenarioResetOwnsCleanDraft = scenarioResetOwnsCleanConnectionDraft(
|
||||
state,
|
||||
connectionMode,
|
||||
);
|
||||
const connectionRecoveryTarget = !scenarioResetOwnsCleanDraft
|
||||
&& !physicalRecoveryRequired
|
||||
const connectionRecoveryTarget = !physicalRecoveryRequired
|
||||
&& !unresolvedAppliedAttempt
|
||||
&& !selectedModeConnected
|
||||
&& !reconfigurationActive
|
||||
@@ -1805,10 +1813,12 @@ export function K1ProvisioningPipeline({
|
||||
connectionRecoveryKey,
|
||||
escapedConnectionRecoveryKey,
|
||||
) && !(searchRequested && !searchDisplayActive);
|
||||
const presentedConnectionRecoveryRequired = connectionRecoveryRequired
|
||||
|| connectionRecoveryVerificationPending;
|
||||
const presentedConnectionRecoveryRequired = !livePreparationPending && (
|
||||
connectionRecoveryRequired || connectionRecoveryVerificationPending
|
||||
);
|
||||
const presentedPhysicalRecoveryRequired = physicalRecoveryRequired
|
||||
|| physicalRecoveryVerificationPending;
|
||||
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
|
||||
const appliedControlSettlementPending = Boolean(
|
||||
unresolvedAppliedAttempt
|
||||
&& ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
|
||||
@@ -1893,10 +1903,12 @@ export function K1ProvisioningPipeline({
|
||||
&& !physicalStopRecoverySettling,
|
||||
);
|
||||
const trustedBinding = trustedConnectionBinding(state);
|
||||
const connectedEndpoint = selectedModeConnected
|
||||
const connectionPresentationEstablished = selectedModeConnected
|
||||
|| livePreparationPending;
|
||||
const connectedEndpoint = connectionPresentationEstablished
|
||||
? selectedModeTopology?.endpoint?.trim() || null
|
||||
: null;
|
||||
const connectedDeviceIdentity = selectedModeConnected
|
||||
const connectedDeviceIdentity = connectionPresentationEstablished
|
||||
? selectedTarget?.label
|
||||
|| retainedSessionLabel
|
||||
|| (trustedBinding?.connectionMode === connectionMode
|
||||
@@ -1951,7 +1963,7 @@ export function K1ProvisioningPipeline({
|
||||
!presentedPhysicalRecoveryRequired
|
||||
&& !presentedConnectionRecoveryRequired
|
||||
&& (
|
||||
selectedModeConnected
|
||||
connectionPresentationEstablished
|
||||
|| explicitProvisioningDraftRetained
|
||||
|| explicitProvisioningDraftContextRetained
|
||||
|| unresolvedAppliedAttempt
|
||||
@@ -2619,10 +2631,13 @@ export function K1ProvisioningPipeline({
|
||||
) return;
|
||||
if (!dispatched.result.succeeded) {
|
||||
setCandidateUnavailableMessage(
|
||||
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
|
||||
dispatched.result.reasonCode === "connection-verify-address-unavailable"
|
||||
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
|
||||
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
setCandidateUnavailableMessage(null);
|
||||
} finally {
|
||||
setReadOnlyReconnectPresentation((current) => (
|
||||
@@ -2699,14 +2714,20 @@ export function K1ProvisioningPipeline({
|
||||
const actionFence = activateRuntimeActionFence(modeAuthority);
|
||||
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
|
||||
try {
|
||||
const result = await verifyConnection(request);
|
||||
const result = await verifyConnection(request, { surfaceErrors: false });
|
||||
if (!runtimeClickIsCurrent(actionFence)) return;
|
||||
if (!result.succeeded) {
|
||||
setCandidateUnavailableMessage(
|
||||
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
|
||||
result.reasonCode === "connection-verify-address-unavailable"
|
||||
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
|
||||
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A successful authoritative Verify supersedes any earlier local
|
||||
// connection banner, including a failure that settled immediately
|
||||
// before the recovered state reached this browser.
|
||||
clearError();
|
||||
if (
|
||||
result.observedState
|
||||
&& isRecoveredPhysicalScanning(
|
||||
@@ -2882,7 +2903,7 @@ export function K1ProvisioningPipeline({
|
||||
|
||||
// Only an explicit public search owns the Step-1 Bluetooth loader.
|
||||
const searchActive = searchDisplayActive;
|
||||
const connectionEstablished = selectedModeConnected;
|
||||
const connectionEstablished = connectionPresentationEstablished;
|
||||
const connectionAttemptOwnsDraft = connectionAttemptSettling
|
||||
|| (
|
||||
connectionAttemptFailed
|
||||
@@ -3103,19 +3124,23 @@ export function K1ProvisioningPipeline({
|
||||
: "Переподключиться"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
width="full"
|
||||
variant={
|
||||
connectionRecoveryTarget || connectionRecoveryVerificationPending
|
||||
? "secondary"
|
||||
: "primary"
|
||||
}
|
||||
icon={<Icon name="search" />}
|
||||
disabled={isBusy || modeResetInFlight}
|
||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||
>
|
||||
Подключить новый K1
|
||||
</Button>
|
||||
{!scenarioResetOwnsCleanDraft ? (
|
||||
<Button
|
||||
width="full"
|
||||
variant={
|
||||
connectionRecoveryTarget || connectionRecoveryVerificationPending
|
||||
? "secondary"
|
||||
: "primary"
|
||||
}
|
||||
icon={<Icon name="search" />}
|
||||
disabled={isBusy || modeResetInFlight}
|
||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||
>
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "Подключить K1 к общей сети"
|
||||
: "Подключить новый K1"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
@@ -3164,7 +3189,9 @@ export function K1ProvisioningPipeline({
|
||||
: presentedPhysicalRecoveryRequired
|
||||
? verificationActionPending
|
||||
? "Переподключение…"
|
||||
: "Нужно выбрать действие"
|
||||
: savedBridgeNetworkSetupRequired
|
||||
? "Нужна настройка сети"
|
||||
: "Нужно выбрать действие"
|
||||
: networkRecoveryRequired
|
||||
? appliedControlSettlementPending
|
||||
? "Подтверждение управления"
|
||||
@@ -3173,8 +3200,8 @@ export function K1ProvisioningPipeline({
|
||||
? connectionReconnectPending
|
||||
? "Переподключение…"
|
||||
: connectionRecoveryTarget
|
||||
? "Нужна проверка"
|
||||
: "Нужен новый поиск"
|
||||
? "Можно переподключиться"
|
||||
: "Подключить заново"
|
||||
: connectionEstablished
|
||||
? "Подключение установлено"
|
||||
: explicitProvisioningDraftStale
|
||||
@@ -3200,7 +3227,6 @@ export function K1ProvisioningPipeline({
|
||||
? "success"
|
||||
: networkRecoveryRequired
|
||||
|| presentedPhysicalRecoveryRequired
|
||||
|| presentedConnectionRecoveryRequired
|
||||
|| explicitProvisioningDraftStale
|
||||
? "warning"
|
||||
: "neutral"
|
||||
@@ -3239,15 +3265,20 @@ export function K1ProvisioningPipeline({
|
||||
aria-busy={physicalReconnectPending || undefined}
|
||||
>
|
||||
<div className="connection-summary" role="status">
|
||||
<span>Прежнее подключение не подтверждено</span>
|
||||
<span>
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "K1 не подключён к сохранённой общей сети"
|
||||
: "Прежнее подключение не подтверждено"}
|
||||
</span>
|
||||
<strong>{physicalRecoveryModeLabel}</strong>
|
||||
<small>
|
||||
{presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}
|
||||
</small>
|
||||
</div>
|
||||
<p className="safety-note">
|
||||
Переподключитесь к прежнему K1 или начните чистое подключение
|
||||
нового устройства.
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
|
||||
: "Переподключитесь к прежнему K1 или начните чистое подключение нового устройства."}
|
||||
</p>
|
||||
{physicalReadOnlyVerificationAvailable ? (
|
||||
<Button
|
||||
@@ -3276,7 +3307,9 @@ export function K1ProvisioningPipeline({
|
||||
disabled={isBusy || modeResetInFlight}
|
||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||
>
|
||||
Подключить новый K1
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "Подключить K1 к общей сети"
|
||||
: "Подключить новый K1"}
|
||||
</Button>
|
||||
{candidateUnavailableMessage ? (
|
||||
<p className="safety-note" role="status">
|
||||
@@ -3289,7 +3322,8 @@ export function K1ProvisioningPipeline({
|
||||
className="field-stack"
|
||||
aria-busy={connectionReconnectPending || undefined}
|
||||
>
|
||||
{correlatedConnectionAttempt && error ? (
|
||||
{correlatedConnectionAttempt && error
|
||||
&& !savedBridgeNetworkSetupRequired ? (
|
||||
<K1OperatorError
|
||||
message={error}
|
||||
diagnostic={errorDiagnostic}
|
||||
@@ -3305,9 +3339,9 @@ export function K1ProvisioningPipeline({
|
||||
<>
|
||||
<div className="connection-summary" role="status">
|
||||
<span>
|
||||
{unknownNetworkOutcomeAttempt
|
||||
? "Результат применения сети не подтверждён"
|
||||
: "Сохранённое подключение требует проверки"}
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "K1 не подключён к сохранённой общей сети"
|
||||
: "Сохранённое подключение"}
|
||||
</span>
|
||||
<strong>
|
||||
{connectionRecoveryModeLabel
|
||||
@@ -3319,13 +3353,24 @@ export function K1ProvisioningPipeline({
|
||||
) : null}
|
||||
</div>
|
||||
<p className="safety-note">
|
||||
{unknownNetworkOutcomeAttempt
|
||||
? "Итог прежней попытки неизвестен. Автоматического повтора не было: сначала проверьте сохранённое подключение без изменений либо начните отдельный новый поиск."
|
||||
: "Проверка читает состояние прежнего K1 и не отправляет настройки сети, START или STOP. Новый поиск — отдельное явное действие для выбора другого устройства."}
|
||||
{savedBridgeNetworkSetupRequired
|
||||
? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
|
||||
: "Переподключение использует сохранённый K1 без изменения сети. Новое подключение запускается отдельно через Bluetooth."}
|
||||
</p>
|
||||
{connectionRecoveryActions}
|
||||
</>
|
||||
)}
|
||||
{scenarioResetOwnsCleanDraft ? (
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!canScan}
|
||||
onClick={() => void repeatDeviceScan()}
|
||||
>
|
||||
Найти по Bluetooth
|
||||
</Button>
|
||||
) : null}
|
||||
{candidateUnavailableMessage ? (
|
||||
<p className="safety-note" role="status">
|
||||
{candidateUnavailableMessage}
|
||||
@@ -3444,7 +3489,7 @@ export function K1ProvisioningPipeline({
|
||||
? "Настройка…"
|
||||
: explicitProvisioningDraftStale
|
||||
? "Результат устарел"
|
||||
: selectedModeConnected
|
||||
: connectionPresentationEstablished
|
||||
? "Готово"
|
||||
: "Ожидает настройки"
|
||||
}
|
||||
@@ -3459,7 +3504,7 @@ export function K1ProvisioningPipeline({
|
||||
? "accent"
|
||||
: networkRecoveryRequired || explicitProvisioningDraftStale
|
||||
? "warning"
|
||||
: selectedModeConnected
|
||||
: connectionPresentationEstablished
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
@@ -3558,7 +3603,7 @@ export function K1ProvisioningPipeline({
|
||||
<ActivityIndicator />
|
||||
<strong>Настройка сети…</strong>
|
||||
</div>
|
||||
) : selectedModeConnected && !changeNetworkDialogue ? (
|
||||
) : connectionPresentationEstablished && !changeNetworkDialogue ? (
|
||||
<div className="field-stack">
|
||||
<div className="connection-summary">
|
||||
<span>Подключение установлено</span>
|
||||
|
||||
@@ -812,6 +812,26 @@ export function readOnlyFailureShowsNetworkUnavailable(
|
||||
&& READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES.has(reasonCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the stronger BLE observation across refreshes and later scan
|
||||
* failures: this saved Bridge needs network setup, not another old-address
|
||||
* reconnect attempt.
|
||||
*/
|
||||
export function savedBridgeRequiresNetworkSetup(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
state?.connection_verification?.reason_code
|
||||
=== "connection-verify-address-unavailable"
|
||||
) return true;
|
||||
const operation = state?.last_operation;
|
||||
return Boolean(
|
||||
operation?.action === "connection.verify"
|
||||
&& operation.status === "failed"
|
||||
&& operation.error?.code === "connection-verify-address-unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresReadOnlyPhysicalRecovery(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
@@ -1201,24 +1221,15 @@ ReadonlyArray<ReadOnlyNetworkObservationAction> = [
|
||||
|
||||
/**
|
||||
* Resolve a recovery Verify target from the public policy, never from a
|
||||
* browser selection. The backend recommendation wins when it names an exact
|
||||
* allowed observation; older compatible projections fall back in the same
|
||||
* current -> configured -> fresh order used by the supervisor.
|
||||
* browser selection. A reconnect must prefer already bound or durable state
|
||||
* over a projected fresh scan: a browser projection cannot prove that the
|
||||
* native BLEDevice is still retained by the backend process. The backend
|
||||
* policy still decides which exact actions are allowed; this helper only
|
||||
* chooses the safest allowed source in current -> configured -> fresh order.
|
||||
*/
|
||||
export function recommendedConnectionRecoveryObservationTarget(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ReadOnlyConnectionObservationTarget | null {
|
||||
const recommended = state?.connection_policy?.recommended_action;
|
||||
const orderedActions = SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.includes(
|
||||
recommended as ReadOnlyNetworkObservationAction,
|
||||
)
|
||||
? [
|
||||
recommended as ReadOnlyNetworkObservationAction,
|
||||
...SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.filter(
|
||||
(action) => action !== recommended,
|
||||
),
|
||||
]
|
||||
: SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY;
|
||||
const sources: Record<
|
||||
ReadOnlyNetworkObservationAction,
|
||||
ReadOnlyConnectionObservationSource
|
||||
@@ -1227,7 +1238,7 @@ export function recommendedConnectionRecoveryObservationTarget(
|
||||
"observe-configured-device-network": "durable-configured-state",
|
||||
"observe-fresh-device-network": "fresh-scan",
|
||||
};
|
||||
for (const action of orderedActions) {
|
||||
for (const action of SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY) {
|
||||
const target = exactPolicyObservationTarget(state, action, sources[action]);
|
||||
if (target?.serverBound) return target;
|
||||
}
|
||||
@@ -1282,6 +1293,13 @@ export function readOnlyConnectionObservationTarget(
|
||||
selectedDeviceId = "",
|
||||
selectedConnectionMode: XgridsConnectionMode | null = null,
|
||||
): ReadOnlyConnectionObservationTarget | null {
|
||||
// An unresolved physical command must first use the exact durable
|
||||
// DeviceInfo path when the supervisor authorizes it. A projected fresh BLE
|
||||
// row can outlive the backend's native BLEDevice and therefore cannot
|
||||
// outrank this read-only reconciliation route.
|
||||
const exactControl = exactControlVerificationTarget(state);
|
||||
if (exactControl) return exactControl;
|
||||
|
||||
const exactFresh = exactPolicyObservationTarget(
|
||||
state,
|
||||
"observe-fresh-device-network",
|
||||
@@ -1327,7 +1345,7 @@ export function readOnlyConnectionObservationTarget(
|
||||
state,
|
||||
"observe-configured-device-network",
|
||||
"durable-configured-state",
|
||||
) ?? exactControlVerificationTarget(state);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,6 +145,12 @@ export type AcquisitionPreparationDraft = Omit<
|
||||
|
||||
export interface CanonicalLivePreparationRequest {
|
||||
acquisition: AcquisitionPreparationDraft;
|
||||
physicalAcceptance: OperatorPresenceConfirmation;
|
||||
}
|
||||
|
||||
function runtimeTimezoneName(): string {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return typeof timezone === "string" && timezone.trim() ? timezone : "UTC";
|
||||
}
|
||||
|
||||
export interface ProvisioningSubmitResult {
|
||||
@@ -677,7 +683,7 @@ export function connectionVerificationFailureMessage(
|
||||
"connection-verify-exact-uuid-scan-timeout":
|
||||
"Mission Core не получил объявление точного сохранённого CoreBluetooth UUID в отведённое окно. Команды K1 не отправлялись; это не является выводом о состоянии устройства.",
|
||||
"connection-verify-address-unavailable":
|
||||
"K1 ответил, но не сообщил адрес в общей сети. Bridge пока не подключён; настройки устройства не менялись.",
|
||||
"K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись.",
|
||||
"connection-verify-target-not-distinguishable-from-baseline":
|
||||
"K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.",
|
||||
"connection-verify-route-mismatch":
|
||||
@@ -1732,6 +1738,27 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (nextState.application_control_session?.inspection_only === true) {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.openApplicationControlSession({
|
||||
...request.physicalAcceptance,
|
||||
timezone_name: runtimeTimezoneName(),
|
||||
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
|
||||
}),
|
||||
);
|
||||
acceptState(nextState);
|
||||
assertOperatorIntentCurrent();
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"connection-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user