Стабилизация переключения Bridge и Quick Connect
Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру. Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения. Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
This commit is contained in:
@@ -207,9 +207,16 @@ export function K1AcquisitionPipeline({
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const protectedSoftwareCommandedTransition = Boolean(
|
||||
activeAcquisition
|
||||
&& activeAcquisition.control_mode === "plugin-commanded"
|
||||
&& activeAcquisition.state !== "prepared"
|
||||
&& !isTerminalAcquisitionState(activeAcquisition.state),
|
||||
);
|
||||
const localReceiverStopExecutable = Boolean(
|
||||
connectionPolicyAllows(state, "stop-local-receiver")
|
||||
&& preparedAcquisition === null,
|
||||
&& preparedAcquisition === null
|
||||
&& !protectedSoftwareCommandedTransition,
|
||||
);
|
||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||
&& physicalStopInFlight;
|
||||
@@ -306,6 +313,10 @@ export function K1AcquisitionPipeline({
|
||||
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
||||
: terminalReadOnlyRecovery
|
||||
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: protectedSoftwareCommandedTransition && !physicalStopPresented
|
||||
? physicalStopIntentSpent
|
||||
? "Команда остановки уже была принята интерфейсом. Повторная команда и локальное завершение заблокированы до нового подтверждённого состояния K1."
|
||||
: "K1 выполняет переход к сканированию. Кнопка остановки появится только после подтверждённого SCANNING."
|
||||
: physicalStopGuidance
|
||||
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
||||
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
||||
@@ -572,7 +583,7 @@ export function K1AcquisitionPipeline({
|
||||
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeAcquisition ? (
|
||||
{activeAcquisition && !protectedSoftwareCommandedTransition ? (
|
||||
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
||||
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
||||
</Button>
|
||||
|
||||
@@ -100,6 +100,31 @@ export function emptyReadOnlyReconnectPresentation():
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS = 2_000;
|
||||
|
||||
/**
|
||||
* Keep short local lifecycle settlements visually silent. Safety interlocks
|
||||
* still take effect immediately; only the progress presentation is deferred.
|
||||
*/
|
||||
export function useDelayedLocalOperationProgress(
|
||||
active: boolean,
|
||||
delayMilliseconds = LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
): boolean {
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = globalThis.setTimeout(
|
||||
() => setVisible(true),
|
||||
delayMilliseconds,
|
||||
);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [active, delayMilliseconds]);
|
||||
return active && visible;
|
||||
}
|
||||
|
||||
export interface PhysicalReopenPresentation {
|
||||
key: string;
|
||||
snapshotRuntimeId: string;
|
||||
@@ -1240,6 +1265,9 @@ export function K1ProvisioningPipeline({
|
||||
targetMode: ConnectionMode;
|
||||
} | null>(null);
|
||||
const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
|
||||
const modeResetProgressVisible = useDelayedLocalOperationProgress(
|
||||
modeResetInFlight,
|
||||
);
|
||||
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -1657,7 +1685,8 @@ export function K1ProvisioningPipeline({
|
||||
? readOnlyReconnectPresentation
|
||||
: null;
|
||||
const connectionRecoveryVerificationPending =
|
||||
currentReadOnlyReconnectPresentation?.kind === "connection";
|
||||
currentReadOnlyReconnectPresentation?.kind === "connection"
|
||||
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
||||
const physicalRecoveryVerificationPending =
|
||||
currentReadOnlyReconnectPresentation?.kind === "physical";
|
||||
const provisioningMutationBusy = Boolean(
|
||||
@@ -1796,7 +1825,7 @@ export function K1ProvisioningPipeline({
|
||||
state,
|
||||
connectionMode,
|
||||
);
|
||||
const connectionRecoveryTarget = !physicalRecoveryRequired
|
||||
const recommendedConnectionRecoveryTarget = !physicalRecoveryRequired
|
||||
&& !unresolvedAppliedAttempt
|
||||
&& !selectedModeConnected
|
||||
&& !reconfigurationActive
|
||||
@@ -1804,6 +1833,13 @@ export function K1ProvisioningPipeline({
|
||||
&& connectionRecoveryObservationAllowed
|
||||
? recommendedConnectionRecoveryObservationTarget(state)
|
||||
: null;
|
||||
// A durable recovery target remains available when the operator returns to
|
||||
// its mode, but it must never leak a Bridge reconnect action into a freshly
|
||||
// selected Quick Connect draft (or vice versa).
|
||||
const connectionRecoveryTarget =
|
||||
recommendedConnectionRecoveryTarget?.connectionMode === connectionMode
|
||||
? recommendedConnectionRecoveryTarget
|
||||
: null;
|
||||
const connectionRecoveryKey = connectionRecoveryEscapeKey({
|
||||
snapshotRuntimeId,
|
||||
attempt: connectionRecoveryAttempt,
|
||||
@@ -3162,10 +3198,16 @@ export function K1ProvisioningPipeline({
|
||||
disabled={modeResetInFlight}
|
||||
variant="split"
|
||||
/>
|
||||
{modeResetInFlight ? (
|
||||
<p className="safety-note" role="status">
|
||||
Завершение прежней локальной границы. Новый поиск не начнётся автоматически.
|
||||
</p>
|
||||
{modeResetProgressVisible ? (
|
||||
<div
|
||||
className="connection-action-progress"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<ActivityIndicator size="compact" />
|
||||
<strong>Переключаем способ подключения…</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{activeScenarioReset ? (
|
||||
<p className="safety-note" role="status">
|
||||
|
||||
@@ -197,6 +197,7 @@ export function K1SpatialControlsView({
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const pluginCommandedAcquisition = acquisition?.control_mode === "plugin-commanded";
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition?.state ?? "",
|
||||
);
|
||||
@@ -327,7 +328,10 @@ export function K1SpatialControlsView({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
|
||||
{!physicalStopPresented
|
||||
&& localReceiverStopAllowed
|
||||
&& !pluginCommandedAcquisition
|
||||
&& !stopping ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user