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:
@@ -95,6 +95,14 @@ Validate the current exact-match profile without device I/O with:
|
||||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
```
|
||||
|
||||
Plugin v0.7.0 adds the backend-owned supervised connection lifecycle. Operator
|
||||
mode choice is a CAS-fenced draft; an explicit Scan commits a safe pre-START
|
||||
mode switch, while Connect reaches Ready only after the exact current
|
||||
`DeviceInfo` authority is confirmed. Configured, active and desired modes are
|
||||
separate facts. Terminal pre-START failures and purely local prepared sessions
|
||||
self-retire without a device command, and an applied network configuration is
|
||||
recovered through a separate read-only Verify instead of replaying Wi-Fi.
|
||||
|
||||
Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
|
||||
the connection matrix behind the existing explicit `network.provision` action.
|
||||
Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
@@ -102,13 +110,19 @@ Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
Connect accepts no browser/API credential: it sends one reviewed fixed 100-byte
|
||||
AP-enable frame to the selected K1, waits up to 15 seconds for the canonical
|
||||
byte-51 AP-ready flag, and keeps that BLE session alive while the macOS adapter
|
||||
performs bounded exact-SSID CoreWLAN discovery and one association. Credentials
|
||||
performs up to 30 seconds of exact-SSID CoreWLAN discovery and one association.
|
||||
AP-ready does not imply that macOS has already observed the RF beacon. Credentials
|
||||
are resolved by a preinstalled exact `3.0.2` firmware provider. Its optional laboratory importer
|
||||
validates the reviewed official archive, extracts the single AP declaration and
|
||||
installs firmware-scoped material in the OS secure store. The macOS helper then
|
||||
materializes the selected device profile entirely inside Keychain before any
|
||||
BLE write. The secret never enters the browser, API, argv, logs or evidence;
|
||||
the importer's short-lived mutable buffer is zeroized after the stdin handoff.
|
||||
The prepared-host adapter uses the accepted Apple-signed
|
||||
`/usr/bin/xcrun swift` runner. It does not runtime-compile an ad-hoc executable,
|
||||
query the standard Wi-Fi Keychain or open a password dialog after the K1 write.
|
||||
Production portability still requires a packaged, properly signed helper with
|
||||
a stable designated identity and explicit CoreWLAN authorization.
|
||||
There is no automatic BLE-write or association retry. A clean host cannot
|
||||
obtain the provider from BLE and the product does not download firmware during
|
||||
connection. Windows/Linux Quick Connect adapters are not planned while that
|
||||
|
||||
@@ -6,17 +6,23 @@ generic application source tree.
|
||||
|
||||
The contribution contains:
|
||||
|
||||
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the three
|
||||
explicit local connection directions: Bridge, Quick Connect and Direct
|
||||
Connect;
|
||||
- `K1ProvisioningPipeline` for explicit BLE discovery and the three local
|
||||
connection directions: Bridge, Quick Connect and Direct Connect;
|
||||
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
|
||||
START checkpoints, local receiver preparation and compatibility file replay;
|
||||
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
|
||||
READY plus steady-green completion gate;
|
||||
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
|
||||
observation-source mapping and scoped styles;
|
||||
- typed v0.6.0 local-network and interactive application-control state plus legacy shadow
|
||||
- typed v0.7.0 supervised connection lifecycle and interactive application-control state plus legacy shadow
|
||||
inspection contracts;
|
||||
- a click-correlated, non-secret provisioning presentation latch: after Apply,
|
||||
Steps 01–02 keep their selected-device/form anatomy with disabled controls
|
||||
until the exact connection attempt becomes reachable or reaches bounded
|
||||
recovery; the Wi-Fi password is cleared before asynchronous dispatch;
|
||||
- policy-gated retirement of an unavailable historical K1 as an explicit
|
||||
local ledger action; it never emits a device command and never bypasses the
|
||||
public `retire-unavailable-physical-target` decision;
|
||||
- `plugin.ts`, which binds the manifest `device.connection` component key to
|
||||
the runtime provider and connection view.
|
||||
|
||||
|
||||
@@ -1,31 +1,264 @@
|
||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import {
|
||||
activeStreamRecoveryPresentation,
|
||||
suppressGenericErrorDuringActiveStreamRecovery,
|
||||
} from "./activeStreamRecovery";
|
||||
import { K1AcquisitionPipeline } from "./components/K1AcquisitionPipeline";
|
||||
import { K1Diagnostics } from "./components/K1Diagnostics";
|
||||
import { K1Metrics } from "./components/K1Metrics";
|
||||
import { K1ProvisioningPipeline } from "./components/K1ProvisioningPipeline";
|
||||
import { K1OperatorError } from "./components/K1OperatorError";
|
||||
import {
|
||||
K1ProvisioningPipeline,
|
||||
unavailablePhysicalRetirementAuthority,
|
||||
} from "./components/K1ProvisioningPipeline";
|
||||
import {
|
||||
backendConnectionTopology,
|
||||
connectionAttemptForRuntimeError,
|
||||
hasControlAuthority,
|
||||
isConfirmedLiveState,
|
||||
isPhysicalStopRecoverySettling,
|
||||
isRecoveredPhysicalScanning,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
isSourceRuntimeBusy,
|
||||
readOnlyConnectionObservationTarget,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
requiresReadOnlyPhysicalRecovery,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { phaseLabel, phaseTone } from "./presentation";
|
||||
import { useXgridsK1Controller } from "./runtimeContext";
|
||||
import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "./runtimeContext";
|
||||
import type { XgridsK1State } from "./api";
|
||||
import {
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
type ConnectionMode,
|
||||
} from "./configuration";
|
||||
|
||||
export { K1OperatorError };
|
||||
|
||||
export function shouldRenderK1GenericRuntimeError(
|
||||
error: string | null | undefined,
|
||||
hasCorrelatedConnectionAttempt: boolean,
|
||||
state: XgridsK1State | null | undefined,
|
||||
errorAction?: string | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
error
|
||||
&& !hasCorrelatedConnectionAttempt
|
||||
&& !suppressGenericErrorDuringActiveStreamRecovery(state, errorAction),
|
||||
);
|
||||
}
|
||||
|
||||
export function physicalRecoveryConnectionDetail(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
if (!requiresReadOnlyPhysicalRecovery(state)) return null;
|
||||
const retirementAvailable = Boolean(
|
||||
unavailablePhysicalRetirementAuthority(state),
|
||||
);
|
||||
const readOnlyVerificationAvailable = Boolean(
|
||||
readOnlyConnectionObservationTarget(state)?.serverBound,
|
||||
);
|
||||
if (retirementAvailable && readOnlyVerificationAvailable) {
|
||||
return "Если прежний K1 снова доступен, проверьте его состояние без изменений: проверка читает состояние и не отправляет START, STOP или настройки сети. Если K1 недоступен постоянно или заменён, его можно локально исключить без связи с устройством.";
|
||||
}
|
||||
if (readOnlyVerificationAvailable) {
|
||||
return "Проверьте состояние прежнего K1 без изменений устройства. Проверка использует сохранённое системой подключение и не отправляет START, STOP или настройки сети.";
|
||||
}
|
||||
if (retirementAvailable) {
|
||||
return "Прежний K1 можно локально исключить без связи с устройством: действие не отправляет START, STOP или настройки сети. После этого можно отдельно выбрать другой K1.";
|
||||
}
|
||||
return "Безопасная сверка прежнего K1 сейчас недоступна. Обновите состояние; новые команды устройству заблокированы.";
|
||||
}
|
||||
|
||||
function connectionPhaseFallbackLabel(phase: string | null | undefined): string {
|
||||
if (phase === "device_selected") return "Выбор выполнен";
|
||||
if (phase === "connected") return "Сетевой адрес получен";
|
||||
return phaseLabel(phase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the disconnected connection job focused on its progressive pipeline.
|
||||
* Persisted topology is evidence, not live control authority. Operational
|
||||
* panels return only when they are actionable or required to finish an
|
||||
* already-started lifecycle, especially STOP and recovery.
|
||||
*/
|
||||
export function shouldRenderK1OperationalPanels(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
hasControlAuthority(state)
|
||||
|| state?.source_mode === "live"
|
||||
|| state?.source_mode === "replay"
|
||||
|| recoverableAcquisition(state)
|
||||
|| state?.acquisition?.cleanup_pending === true
|
||||
|| requiresCanonicalStopAfterTerminalLocalFailure(state)
|
||||
|| isRecoveredPhysicalScanning(state)
|
||||
|| isPhysicalStopRecoverySettling(state)
|
||||
|| activeStreamRecoveryPresentation(state) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function K1ConnectionPipelines({
|
||||
controller,
|
||||
desiredConnectionMode,
|
||||
onDesiredConnectionModeChange,
|
||||
operationalPanelsVisible,
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
sourceLabel,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
desiredConnectionMode: ConnectionMode;
|
||||
onDesiredConnectionModeChange: (mode: ConnectionMode) => void | Promise<void>;
|
||||
operationalPanelsVisible: boolean;
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
sourceLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{operationalPanelsVisible ? <K1Metrics controller={controller} /> : null}
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
desiredMode={desiredConnectionMode}
|
||||
onDesiredModeChange={onDesiredConnectionModeChange}
|
||||
/>
|
||||
{operationalPanelsVisible ? (
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
desiredConnectionMode={desiredConnectionMode}
|
||||
openSpatialScene={openSpatialScene}
|
||||
activateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, error, refresh, clearError } = controller;
|
||||
const {
|
||||
state,
|
||||
error,
|
||||
errorDiagnostic,
|
||||
errorCorrelation,
|
||||
refresh,
|
||||
clearError,
|
||||
} = controller;
|
||||
const [desiredConnectionMode, setDesiredConnectionMode] = useState<ConnectionMode>(
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
);
|
||||
const desiredModeInitialized = useRef(false);
|
||||
const desiredModeLocallyDirty = useRef(false);
|
||||
const hydratedScenarioResetKey = useRef<string | null>(null);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
|
||||
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
|
||||
const recoveredPhysicalScanning = physicalRecoveryRequired
|
||||
&& state?.application_control_session?.state === "scanning"
|
||||
&& state.application_control_session.can_stop === true;
|
||||
const physicalRecoveryDetail = physicalRecoveryConnectionDetail(state);
|
||||
const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
|
||||
errorCorrelation,
|
||||
state,
|
||||
);
|
||||
const showGenericRuntimeError = shouldRenderK1GenericRuntimeError(
|
||||
error,
|
||||
Boolean(correlatedConnectionAttempt),
|
||||
state,
|
||||
errorCorrelation?.action,
|
||||
);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay"
|
||||
&& state?.acquisition?.state === "failed"
|
||||
&& !releasedAcquisitionFailure;
|
||||
const projectedPhase = releasedAcquisitionFailure && state?.phase === "error"
|
||||
? "idle"
|
||||
: state?.phase;
|
||||
const connectionTopology = backendConnectionTopology(state);
|
||||
const effectiveDesiredConnectionMode = desiredModeInitialized.current
|
||||
? desiredConnectionMode
|
||||
: state?.desired_connection_mode
|
||||
?? (connectionTopology?.status === "active"
|
||||
? connectionTopology.connectionMode
|
||||
: DEFAULT_CONNECTION_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state || desiredModeInitialized.current) return;
|
||||
desiredModeInitialized.current = true;
|
||||
setDesiredConnectionMode(
|
||||
state.desired_connection_mode
|
||||
?? (connectionTopology?.status === "active"
|
||||
? connectionTopology.connectionMode
|
||||
: DEFAULT_CONNECTION_MODE),
|
||||
);
|
||||
}, [connectionTopology, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desiredModeInitialized.current) return;
|
||||
const backendDesiredMode = state?.desired_connection_mode;
|
||||
if (!backendDesiredMode) return;
|
||||
const scenarioReset = state?.connection_scenario_reset;
|
||||
const scenarioResetKey = scenarioReset
|
||||
&& scenarioReset.revision === state?.desired_connection_mode_revision
|
||||
&& scenarioReset.desired_mode === backendDesiredMode
|
||||
&& state?.snapshot_runtime_id?.trim()
|
||||
? `${state.snapshot_runtime_id}:${scenarioReset.revision}`
|
||||
: null;
|
||||
if (scenarioResetKey && hydratedScenarioResetKey.current !== scenarioResetKey) {
|
||||
// The shell emergency reset is an authoritative new backend revision.
|
||||
// It must retire a locally dirty selector too; an older dirty browser
|
||||
// draft cannot keep showing Quick/Direct after canonical Bridge won.
|
||||
hydratedScenarioResetKey.current = scenarioResetKey;
|
||||
desiredModeLocallyDirty.current = false;
|
||||
setDesiredConnectionMode(backendDesiredMode);
|
||||
return;
|
||||
}
|
||||
if (backendDesiredMode === desiredConnectionMode) {
|
||||
desiredModeLocallyDirty.current = false;
|
||||
return;
|
||||
}
|
||||
// Every dropdown gesture is now an explicit backend scenario-reset CAS.
|
||||
// The callback may publish its accepted mode one render before the hook's
|
||||
// authoritative snapshot arrives, so passive polling must not overwrite
|
||||
// that in-flight acknowledgement. Once the backend echoes the exact mode
|
||||
// above, the dirty fence clears and later authoritative changes hydrate it.
|
||||
if (desiredModeLocallyDirty.current) return;
|
||||
setDesiredConnectionMode(backendDesiredMode);
|
||||
}, [
|
||||
desiredConnectionMode,
|
||||
state?.connection_scenario_reset,
|
||||
state?.desired_connection_mode,
|
||||
state?.desired_connection_mode_revision,
|
||||
state?.snapshot_runtime_id,
|
||||
]);
|
||||
|
||||
const updateDesiredConnectionMode = (mode: ConnectionMode) => {
|
||||
desiredModeLocallyDirty.current = mode !== state?.desired_connection_mode;
|
||||
setDesiredConnectionMode(mode);
|
||||
};
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: projectedPhase === "error" || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: confirmedLive || state?.source_mode === "replay"
|
||||
? "success"
|
||||
@@ -34,56 +267,88 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: "neutral";
|
||||
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceLabel
|
||||
: phaseLabel(state?.phase);
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.title
|
||||
: physicalStopRecoverySettling
|
||||
? "Завершение остановки"
|
||||
: recoveredPhysicalScanning
|
||||
? "Сканирование продолжается"
|
||||
: physicalRecoveryRequired
|
||||
? "Требуется действие"
|
||||
: projectedPhase === "error"
|
||||
? connectionPhaseFallbackLabel(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
? "Подключение установлено"
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "durable"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "applied"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "last-known"
|
||||
? "Подключение отсутствует"
|
||||
: connectionPhaseFallbackLabel(projectedPhase);
|
||||
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceTone
|
||||
: phaseTone(state?.phase);
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
? phaseTone(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
? "success"
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "neutral"
|
||||
: "neutral";
|
||||
const connectionPhaseDetail = physicalStopRecoverySettling
|
||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.detail
|
||||
: recoveredPhysicalScanning
|
||||
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
||||
: physicalRecoveryRequired
|
||||
? physicalRecoveryDetail
|
||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||
? "Начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
? "Готово к новой сессии."
|
||||
: "Ожидается состояние локального контура.";
|
||||
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
|
||||
|
||||
return (
|
||||
<div className="device-workspace xgrids-k1-plugin">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>Закрыть</Button>
|
||||
</div>
|
||||
</aside>
|
||||
{showGenericRuntimeError && error ? (
|
||||
<K1OperatorError
|
||||
message={error}
|
||||
diagnostic={errorDiagnostic}
|
||||
onRefresh={() => void refresh()}
|
||||
onClear={clearError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">XGRIDS K1 · PLUGIN UI</span>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНОЕ ПОДКЛЮЧЕНИЕ</span>
|
||||
<h2>Подключение {model.displayName}</h2>
|
||||
<p>BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.</p>
|
||||
<p>Выберите способ связи и последовательно установите подключение.</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
<span>{connectionPhaseDetail}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<K1Metrics controller={controller} />
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
</div>
|
||||
<K1ConnectionPipelines
|
||||
controller={controller}
|
||||
desiredConnectionMode={effectiveDesiredConnectionMode}
|
||||
onDesiredConnectionModeChange={updateDesiredConnectionMode}
|
||||
operationalPanelsVisible={operationalPanelsVisible}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
sourceLabel={sourceLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@ export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
|
||||
{
|
||||
value: "bridge",
|
||||
label: "Общая сеть · Bridge",
|
||||
description: "Mission Core передаёт K1 реквизиты существующей общей сети.",
|
||||
description: "Передача реквизитов существующей общей сети.",
|
||||
},
|
||||
{
|
||||
value: "quick-connect",
|
||||
label: "Точка доступа K1 · Quick Connect",
|
||||
description: "Лабораторный режим: Mission Core включает AP K1 и подключает только заранее подготовленный хост. Для обычной работы используйте Bridge.",
|
||||
label: "Локальная сеть · Quick Connect",
|
||||
description: "Связь через отдельную локальную сеть. Для обычной работы используйте Bridge.",
|
||||
},
|
||||
{
|
||||
value: "direct-connect",
|
||||
label: "Хотспот контроллера · Direct Connect",
|
||||
description: "Mission Core передаёт K1 реквизиты хотспота управляющего устройства.",
|
||||
description: "Передача реквизитов хотспота контроллера.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApiError, type XgridsK1State } from "./api";
|
||||
|
||||
export interface ExactApplicationControlCas {
|
||||
expected_session_generation: number;
|
||||
expected_state_revision: number;
|
||||
}
|
||||
|
||||
export interface ExactAcquisitionControlCas {
|
||||
expected_control_session_generation: number;
|
||||
expected_control_state_revision: number;
|
||||
}
|
||||
|
||||
interface ControlSessionVersion {
|
||||
sessionGeneration: number;
|
||||
stateRevision: number;
|
||||
}
|
||||
|
||||
function exactControlSessionVersion(
|
||||
state: XgridsK1State | null | undefined,
|
||||
actionLabel: string,
|
||||
): ControlSessionVersion {
|
||||
const session = state?.application_control_session;
|
||||
const sessionGeneration = session?.session_generation;
|
||||
const stateRevision = session?.state_revision;
|
||||
if (
|
||||
!Number.isSafeInteger(sessionGeneration)
|
||||
|| (sessionGeneration ?? -1) < 0
|
||||
|| !Number.isSafeInteger(stateRevision)
|
||||
|| (stateRevision ?? -1) < 0
|
||||
) {
|
||||
throw new ApiError(
|
||||
`Команда ${actionLabel} не отправлена: последнее принятое состояние не содержит целые session_generation и state_revision управляющей сессии. Обновите состояние K1 и повторите отдельным действием.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionGeneration: sessionGeneration as number,
|
||||
stateRevision: stateRevision as number,
|
||||
};
|
||||
}
|
||||
|
||||
export function exactApplicationControlCas(
|
||||
latestAcceptedState: XgridsK1State | null | undefined,
|
||||
actionLabel: string,
|
||||
): ExactApplicationControlCas {
|
||||
const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
|
||||
return {
|
||||
expected_session_generation: version.sessionGeneration,
|
||||
expected_state_revision: version.stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
export function exactAcquisitionControlCas(
|
||||
latestAcceptedState: XgridsK1State | null | undefined,
|
||||
actionLabel: string,
|
||||
): ExactAcquisitionControlCas {
|
||||
const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
|
||||
return {
|
||||
expected_control_session_generation: version.sessionGeneration,
|
||||
expected_control_state_revision: version.stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
export function acquisitionMutationUsesControlSession(
|
||||
latestAcceptedState: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const session = latestAcceptedState?.application_control_session;
|
||||
return Boolean(
|
||||
session
|
||||
&& session.mode === "interactive-canonical"
|
||||
&& !["idle", "closed", "completed"].includes(session.state),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
isXgridsHostFailureDiagnostic,
|
||||
type XgridsHostDiagnosticCode,
|
||||
type XgridsHostDiagnosticAction,
|
||||
type XgridsHostDiagnosticDomain,
|
||||
type XgridsHostDiagnosticImpact,
|
||||
type XgridsHostFailureDiagnostic,
|
||||
type XgridsOperation,
|
||||
} from "./api";
|
||||
|
||||
const CODE_LABELS: Record<XgridsHostDiagnosticCode, string> = {
|
||||
"host.bluetooth.permission-denied":
|
||||
"macOS не разрешила Mission Core использовать Bluetooth.",
|
||||
"host.bluetooth.adapter-powered-off":
|
||||
"Bluetooth на этом Mac выключен.",
|
||||
"host.bluetooth.adapter-unavailable":
|
||||
"Системный Bluetooth-адаптер сейчас недоступен.",
|
||||
"host.bluetooth.runtime-unavailable":
|
||||
"Локальный Bluetooth runtime не готов к новой операции.",
|
||||
"host.bluetooth.operation-timeout":
|
||||
"Bluetooth-операция не завершилась за ограниченное время.",
|
||||
"host.wifi.permission-denied":
|
||||
"macOS не разрешила Mission Core читать состояние Wi‑Fi.",
|
||||
"host.wifi.adapter-powered-off":
|
||||
"Wi‑Fi на этом Mac выключен.",
|
||||
"host.wifi.interface-unavailable":
|
||||
"Системный Wi‑Fi-интерфейс сейчас недоступен.",
|
||||
"host.wifi.ssid-unavailable":
|
||||
"macOS не сообщила имя текущей Wi‑Fi-сети.",
|
||||
"host.wifi.operation-timeout":
|
||||
"Операция с Wi‑Fi не завершилась за ограниченное время.",
|
||||
"host.wifi.association-failed":
|
||||
"Mac не подтвердил подключение к ожидаемой Wi‑Fi-сети.",
|
||||
"host.keychain.interaction-required":
|
||||
"Связка ключей требует явного подтверждения оператора.",
|
||||
"host.keychain.permission-denied":
|
||||
"macOS запретила чтение профиля подключения.",
|
||||
"host.keychain.unavailable":
|
||||
"Профиль подключения сейчас недоступен в связке ключей.",
|
||||
"host.route.unavailable":
|
||||
"Прямой локальный маршрут к адресу подключения не найден.",
|
||||
"host.tcp.connection-refused":
|
||||
"Управляющий TCP endpoint отклонил соединение.",
|
||||
"host.tcp.connection-timeout":
|
||||
"Управляющий TCP endpoint не ответил за ограниченное время.",
|
||||
"host.tcp.endpoint-unavailable":
|
||||
"Управляющий TCP endpoint недоступен из текущей сети.",
|
||||
"host.mqtt.connection-timeout":
|
||||
"Управляющий MQTT-канал не открылся за ограниченное время.",
|
||||
"host.mqtt.connection-refused":
|
||||
"Управляющий MQTT-канал отклонил соединение.",
|
||||
"host.mqtt.transport-unavailable":
|
||||
"Транспорт управляющего MQTT-канала недоступен.",
|
||||
"host.filesystem.permission-denied":
|
||||
"Mission Core не может записать обязательные данные операции в локальное хранилище.",
|
||||
"host.filesystem.ledger-unavailable":
|
||||
"Журнал безопасного результата операции недоступен или не подтверждён.",
|
||||
};
|
||||
|
||||
const DOMAIN_LABELS: Record<XgridsHostDiagnosticDomain, string> = {
|
||||
corebluetooth: "Bluetooth macOS",
|
||||
corewlan: "Wi‑Fi macOS",
|
||||
keychain: "Связка ключей macOS",
|
||||
route: "Локальный сетевой маршрут",
|
||||
tcp: "Управляющий TCP endpoint",
|
||||
mqtt: "Управляющий канал MQTT",
|
||||
filesystem: "Локальное хранилище Mission Core",
|
||||
};
|
||||
|
||||
const IMPACT_LABELS: Record<XgridsHostDiagnosticImpact, string> = {
|
||||
discovery: "Поиск Bluetooth сейчас недоступен.",
|
||||
"host-network": "Сетевой путь между этим компьютером и локальным контуром недоступен.",
|
||||
control: "Управляющая связь не установлена; команды не повторяются автоматически.",
|
||||
"durable-safety": "Надёжная фиксация результата операции недоступна; новая команда заблокирована.",
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<XgridsHostDiagnosticAction, string> = {
|
||||
"grant-bluetooth-permission":
|
||||
"Разрешите Mission Core доступ к Bluetooth в системных настройках macOS, затем повторите действие вручную.",
|
||||
"power-on-bluetooth":
|
||||
"Включите Bluetooth на этом Mac и запустите новый поиск вручную.",
|
||||
"restore-bluetooth-adapter":
|
||||
"Восстановите доступность Bluetooth-адаптера macOS и перезапустите локальный сервис перед новой попыткой.",
|
||||
"grant-wifi-permission":
|
||||
"Разрешите Mission Core доступ к данным Wi‑Fi в системных настройках macOS, затем повторите действие вручную.",
|
||||
"power-on-wifi":
|
||||
"Включите Wi‑Fi на этом Mac и заново выберите требуемый способ подключения.",
|
||||
"restore-wifi-interface":
|
||||
"Восстановите системный Wi‑Fi-интерфейс macOS перед новой попыткой подключения.",
|
||||
"unlock-or-authorize-keychain":
|
||||
"Разблокируйте связку ключей macOS и подтвердите доступ Mission Core к профилю подключения.",
|
||||
"review-keychain-access":
|
||||
"Разрешите Mission Core чтение профиля подключения в связке ключей macOS.",
|
||||
"join-expected-network":
|
||||
"Установите связь этого Mac с ожидаемой локальной сетью и повторите действие.",
|
||||
"inspect-host-route":
|
||||
"Восстановите прямой локальный маршрут к адресу подключения.",
|
||||
"verify-broker-endpoint":
|
||||
"Восстановите доступность управляющего endpoint из текущей сети; команда автоматически не повторяется.",
|
||||
"inspect-local-storage":
|
||||
"Освободите место и восстановите доступ к локальному хранилищу Mission Core до следующей операции.",
|
||||
"restart-local-service":
|
||||
"Перезапустите канонический локальный сервис Mission Core и после загрузки обновите состояние.",
|
||||
"explicit-retry":
|
||||
"После устранения причины повторите действие отдельным нажатием; автоматического повтора нет.",
|
||||
};
|
||||
|
||||
export interface HostFailureDiagnosticPresentation {
|
||||
codeLabel: string;
|
||||
domainLabel: string;
|
||||
impactLabel: string;
|
||||
operatorActionLabel: string;
|
||||
}
|
||||
|
||||
export function hostFailureDiagnosticPresentation(
|
||||
value: unknown,
|
||||
): HostFailureDiagnosticPresentation | null {
|
||||
if (!isXgridsHostFailureDiagnostic(value)) return null;
|
||||
return {
|
||||
codeLabel: CODE_LABELS[value.code],
|
||||
domainLabel: DOMAIN_LABELS[value.domain],
|
||||
impactLabel: IMPACT_LABELS[value.impact],
|
||||
operatorActionLabel: ACTION_LABELS[value.operator_action],
|
||||
};
|
||||
}
|
||||
|
||||
export function operationHostFailureDiagnostic(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): XgridsHostFailureDiagnostic | null {
|
||||
const diagnostic = operation?.error?.host_diagnostic;
|
||||
return isXgridsHostFailureDiagnostic(diagnostic) ? diagnostic : null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,28 @@ export const xgridsK1Actions = Object.freeze({
|
||||
xgridsK1Manifest,
|
||||
"calibration.device-snapshot.read",
|
||||
),
|
||||
connectionModeSelect: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"connection.mode.select",
|
||||
),
|
||||
connectionReconfigurePrepare: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"connection.reconfigure.prepare",
|
||||
),
|
||||
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
|
||||
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
|
||||
configuredEndpointProbe: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"connection.endpoint-probe",
|
||||
),
|
||||
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
|
||||
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
|
||||
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
|
||||
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
|
||||
acquisitionForceFinishLocal: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"acquisition.force-finish-local",
|
||||
),
|
||||
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
|
||||
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
|
||||
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
|
||||
@@ -54,4 +70,16 @@ export const xgridsK1Actions = Object.freeze({
|
||||
xgridsK1Manifest,
|
||||
"application-control.session.close",
|
||||
),
|
||||
physicalCommandReconcile: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"physical-command.reconcile",
|
||||
),
|
||||
physicalCommandRetireUnavailable: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"physical-command.retire-unavailable",
|
||||
),
|
||||
physicalCommandReopenRetiredReconciliation: requirePluginAction(
|
||||
xgridsK1Manifest,
|
||||
"physical-command.reopen-retired-reconciliation",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
],
|
||||
[/Foxglove/gi, "локальный мост визуализации"],
|
||||
[/MacBook/gi, "компьютер"],
|
||||
[/\bK1\b/g, "устройство"],
|
||||
];
|
||||
|
||||
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||
|
||||
@@ -3,15 +3,28 @@ import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDelivery,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourcePresentationLease,
|
||||
ObservationSourceProvider,
|
||||
} from "@mission-core/plugin-sdk";
|
||||
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||
import {
|
||||
activeStreamRecoveredBrowserAuthority,
|
||||
activeStreamRecoveryOwnsPresentationDecision,
|
||||
activeStreamRecoveryPresentationAuthority,
|
||||
type ActiveStreamRecoveryPresentationAuthority,
|
||||
} from "./activeStreamRecovery";
|
||||
import {
|
||||
confirmedRuntimeSourceMode,
|
||||
effectiveAcquisition,
|
||||
hasAuthoritativeData,
|
||||
hasControlAuthority,
|
||||
} from "./lifecycle";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import type {
|
||||
XgridsCameraPreviewDelivery,
|
||||
XgridsK1State,
|
||||
XgridsSensorCatalogStream,
|
||||
} from "./api";
|
||||
import { isXgridsActiveStreamRecovery } from "./api";
|
||||
|
||||
function providerFor(
|
||||
state: XgridsK1State,
|
||||
@@ -22,30 +35,72 @@ function providerFor(
|
||||
pluginVersion: xgridsK1Manifest.metadata.version,
|
||||
modelId: state.device_ref?.model_id || activeModel.id,
|
||||
compatibilityProfileId:
|
||||
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
|
||||
(state.connection_supervisor?.observed.device_identity.state === "verified"
|
||||
? state.connection_supervisor.observed.device_identity.compatibility_profile_id
|
||||
: null)
|
||||
?? state.device_session?.compatibility_profile_id
|
||||
?? state.compatibility?.profile_id
|
||||
?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function bindingFor(state: XgridsK1State) {
|
||||
function bindingFor(
|
||||
state: XgridsK1State,
|
||||
recoveryAuthority: ActiveStreamRecoveryPresentationAuthority | null,
|
||||
) {
|
||||
const acquisition = effectiveAcquisition(state);
|
||||
const controlAuthoritative = hasControlAuthority(state);
|
||||
const recoveryAuthoritative = recoveryAuthority !== null;
|
||||
return {
|
||||
deviceId: state.device_ref?.device_id ?? null,
|
||||
deviceSessionId: state.device_session?.device_session_id ?? null,
|
||||
// A legacy snapshot may retain a selected device and session long after
|
||||
// the control topology has disappeared. Do not publish those values as a
|
||||
// live host binding until the supervisor has re-attested the topology.
|
||||
deviceId: recoveryAuthoritative
|
||||
? acquisition?.device_id?.trim() || null
|
||||
: controlAuthoritative ? state.device_ref?.device_id ?? null : null,
|
||||
deviceSessionId: recoveryAuthoritative
|
||||
? acquisition?.device_session_id?.trim() || null
|
||||
: controlAuthoritative ? state.device_session?.device_session_id ?? null : null,
|
||||
acquisitionId: acquisition?.acquisition_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryPresentationLease(
|
||||
authority: ActiveStreamRecoveryPresentationAuthority,
|
||||
): ObservationSourcePresentationLease {
|
||||
return {
|
||||
kind: "active-stream-recovery",
|
||||
runtimeId: authority.snapshotRuntimeId,
|
||||
acquisitionId: authority.acquisitionId,
|
||||
acquisitionStateRevision: authority.acquisitionStateRevision,
|
||||
producerGeneration: authority.runtimeProducerGeneration,
|
||||
recoveryGeneration: authority.recoveryGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
|
||||
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
|
||||
}
|
||||
|
||||
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
|
||||
function spatialAvailability(
|
||||
state: XgridsK1State,
|
||||
recoveryAuthoritative: boolean,
|
||||
recoveryOwnsPresentation: boolean,
|
||||
): ObservationSourceAvailability {
|
||||
const mode = confirmedRuntimeSourceMode(state);
|
||||
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
|
||||
if (state.rerun_grpc_url?.trim()) return "available";
|
||||
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||
if (state.device_session?.connectivity === "connected") return "available";
|
||||
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
|
||||
if (mode === "replay" && state.rerun_grpc_url?.trim()) return "streaming";
|
||||
const declared = catalogDeclares(state, "spatial.point-cloud.live");
|
||||
if (recoveryAuthoritative && state.rerun_grpc_url?.trim()) return "connecting";
|
||||
if (recoveryOwnsPresentation) return declared ? "degraded" : "unavailable";
|
||||
if (!hasControlAuthority(state)) return declared ? "unverified" : "unavailable";
|
||||
if (mode === "live" && state.rerun_grpc_url?.trim() && hasAuthoritativeData(state)) {
|
||||
return "streaming";
|
||||
}
|
||||
if (["stalled", "lost"].includes(
|
||||
state.connection_supervisor?.observed.data_plane.state ?? "idle",
|
||||
)) return "degraded";
|
||||
if (state.rerun_grpc_url?.trim() || declared) return "available";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
|
||||
@@ -167,15 +222,42 @@ function browserDelivery(
|
||||
return { id, kind: value.kind, url, mediaType };
|
||||
}
|
||||
|
||||
function sameBrowserDelivery(
|
||||
left: ObservationSourceDelivery | null,
|
||||
right: ObservationSourceDelivery | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
left
|
||||
&& right
|
||||
&& left.kind === "mse-fmp4-websocket"
|
||||
&& right.kind === "mse-fmp4-websocket"
|
||||
&& left.id === right.id
|
||||
&& left.url === right.url
|
||||
&& left.mediaType === right.mediaType,
|
||||
);
|
||||
}
|
||||
|
||||
function cameraAvailability(
|
||||
state: XgridsK1State,
|
||||
stream: XgridsSensorCatalogStream,
|
||||
selected: boolean,
|
||||
delivery: ObservationSourceDelivery | null,
|
||||
attested: boolean,
|
||||
recoverySelected: boolean,
|
||||
exactCurrentEpochReady: boolean,
|
||||
): ObservationSourceAvailability {
|
||||
if (recoverySelected) {
|
||||
return exactCurrentEpochReady
|
||||
? "streaming"
|
||||
: state.camera_preview?.phase?.trim().toLowerCase() === "degraded"
|
||||
? "degraded"
|
||||
: "connecting";
|
||||
}
|
||||
if (!attested) return "unverified";
|
||||
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||
if (!hasControlAuthority(state)) return "degraded";
|
||||
if (["stalled", "lost"].includes(
|
||||
state.connection_supervisor?.observed.data_plane.state ?? "idle",
|
||||
)) return "degraded";
|
||||
const base = catalogAvailability(stream.availability);
|
||||
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
|
||||
|
||||
@@ -188,12 +270,82 @@ function cameraAvailability(
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
function exactCurrentCameraEpochReady(state: XgridsK1State): boolean {
|
||||
const recovery = state.connection_recovery;
|
||||
const previewGeneration = state.camera_preview?.generation;
|
||||
if (
|
||||
!isXgridsActiveStreamRecovery(recovery)
|
||||
|| recovery.camera_media_state !== "ready"
|
||||
|| recovery.camera_media_ready !== true
|
||||
|| !Number.isInteger(previewGeneration)
|
||||
|| (previewGeneration ?? 0) < 1
|
||||
) return false;
|
||||
const epoch = recovery.camera_epoch;
|
||||
return Boolean(
|
||||
epoch
|
||||
&& epoch.generation === previewGeneration
|
||||
&& epoch.init_committed === true
|
||||
&& epoch.first_media_committed === true
|
||||
&& epoch.committed_media_segment_count > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function recoveryCameraTupleIsExact(
|
||||
state: XgridsK1State,
|
||||
authority: ActiveStreamRecoveryPresentationAuthority | null,
|
||||
provider: ObservationSourceProvider,
|
||||
): boolean {
|
||||
const acquisition = state.acquisition;
|
||||
const deviceId = acquisition?.device_id?.trim();
|
||||
const deviceSessionId = acquisition?.device_session_id?.trim();
|
||||
const compatibilityProfileId = acquisition?.compatibility_profile_id?.trim();
|
||||
return Boolean(
|
||||
authority
|
||||
&& authority.recovery.camera_recovery === "owned"
|
||||
&& acquisition
|
||||
&& acquisition.acquisition_id.trim() === authority.acquisitionId
|
||||
&& deviceId
|
||||
&& deviceSessionId
|
||||
&& compatibilityProfileId
|
||||
&& state.device_ref?.device_id?.trim() === deviceId
|
||||
&& state.device_session?.device_session_id?.trim() === deviceSessionId
|
||||
&& state.device_session?.device_id?.trim() === deviceId
|
||||
&& state.device_session?.compatibility_profile_id?.trim() === compatibilityProfileId
|
||||
&& provider.compatibilityProfileId?.trim() === compatibilityProfileId
|
||||
);
|
||||
}
|
||||
|
||||
function cameraRecoveryPhaseRetainable(state: XgridsK1State): boolean {
|
||||
return [
|
||||
"active",
|
||||
"buffering",
|
||||
"connecting",
|
||||
"degraded",
|
||||
"ready",
|
||||
"reconnecting",
|
||||
"streaming",
|
||||
].includes(state.camera_preview?.phase?.trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
export function xgridsK1ObservationSources(
|
||||
state: XgridsK1State,
|
||||
activeModel: DeviceModelDefinition,
|
||||
): ObservationSourceDescriptor[] {
|
||||
const provider = providerFor(state, activeModel);
|
||||
const binding = bindingFor(state);
|
||||
const recoveryAuthority = activeStreamRecoveryPresentationAuthority(state);
|
||||
const recoveredBrowserAuthority = activeStreamRecoveredBrowserAuthority(state);
|
||||
const browserLineageAuthority = recoveryAuthority ?? recoveredBrowserAuthority;
|
||||
const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
|
||||
const recoveryAuthoritative = recoveryAuthority !== null;
|
||||
const presentationLease = browserLineageAuthority
|
||||
? recoveryPresentationLease(browserLineageAuthority)
|
||||
: null;
|
||||
const binding = bindingFor(state, browserLineageAuthority);
|
||||
const replayAuthoritative = state.source_mode === "replay";
|
||||
const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
|
||||
const spatialPreviewUrl = replayAuthoritative || dataAuthoritative || recoveryAuthoritative
|
||||
? state.rerun_grpc_url?.trim() || null
|
||||
: null;
|
||||
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
|
||||
const descriptorId = (sourceId: string) =>
|
||||
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
|
||||
@@ -205,12 +357,22 @@ export function xgridsK1ObservationSources(
|
||||
description: "Облако точек, поза и траектория в общей 3D-сцене",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: spatialAvailability(state),
|
||||
availability: spatialAvailability(
|
||||
state,
|
||||
recoveryAuthoritative,
|
||||
recoveryOwnsPresentation,
|
||||
),
|
||||
transport: "rerun-grpc",
|
||||
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
|
||||
previewUrl: state.rerun_grpc_url?.trim() || null,
|
||||
previewUrl: spatialPreviewUrl,
|
||||
delivery: null,
|
||||
activation: null,
|
||||
presentationLease: (
|
||||
recoveryAuthoritative
|
||||
|| (recoveredBrowserAuthority !== null && dataAuthoritative)
|
||||
) && spatialPreviewUrl
|
||||
? presentationLease
|
||||
: null,
|
||||
provider,
|
||||
binding,
|
||||
capabilities: {
|
||||
@@ -234,9 +396,27 @@ export function xgridsK1ObservationSources(
|
||||
const sourceId = stream.source_id?.trim();
|
||||
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
|
||||
}
|
||||
const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
|
||||
const supervisor = state.connection_supervisor;
|
||||
const verifiedControl = state.application_control_session?.verified_control;
|
||||
const attested = Boolean(
|
||||
hasControlAuthority(state)
|
||||
&& provider.compatibilityProfileId
|
||||
&& binding.deviceSessionId
|
||||
&& verifiedControl
|
||||
&& supervisor?.observed.control_plane.session_id === verifiedControl.control_session_id
|
||||
&& supervisor.observed.device_identity.logical_device_id
|
||||
=== verifiedControl.logical_device_id
|
||||
&& supervisor.observed.device_identity.compatibility_profile_id
|
||||
=== verifiedControl.compatibility_profile_id,
|
||||
);
|
||||
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
|
||||
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
|
||||
const exactCameraMediaReady = exactCurrentCameraEpochReady(state);
|
||||
const browserLineageCameraTupleExact = recoveryCameraTupleIsExact(
|
||||
state,
|
||||
browserLineageAuthority,
|
||||
provider,
|
||||
);
|
||||
|
||||
const cameras = cameraRows.flatMap<ObservationSourceDescriptor>((stream) => {
|
||||
const sourceId = stream.source_id?.trim();
|
||||
@@ -249,19 +429,58 @@ export function xgridsK1ObservationSources(
|
||||
const activationValid = Boolean(
|
||||
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
|
||||
);
|
||||
const selected = Boolean(
|
||||
attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
|
||||
const normallySelected = Boolean(
|
||||
!recoveryOwnsPresentation
|
||||
&& attested
|
||||
&& activationValid
|
||||
&& rawActivation?.selected === true
|
||||
&& activeSourceId === sourceId
|
||||
&& exactCameraMediaReady,
|
||||
);
|
||||
const streamDelivery = browserDelivery(stream.delivery);
|
||||
const previewDelivery = browserDelivery(state.camera_preview?.delivery);
|
||||
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
|
||||
const retainedDelivery = browserDelivery(candidateDelivery);
|
||||
const deliveryConsistent = !stream.delivery || !state.camera_preview?.delivery
|
||||
|| sameBrowserDelivery(streamDelivery, previewDelivery);
|
||||
const streamRecoveryAvailable = [
|
||||
"available",
|
||||
"connecting",
|
||||
"degraded",
|
||||
"streaming",
|
||||
].includes(catalogAvailability(stream.availability));
|
||||
const browserLineageSelected = Boolean(
|
||||
browserLineageCameraTupleExact
|
||||
&& activationValid
|
||||
&& maxActive === 1
|
||||
&& rawActivation?.selected === true
|
||||
&& activeSourceId === sourceId
|
||||
&& cameraRecoveryPhaseRetainable(state)
|
||||
&& streamRecoveryAvailable
|
||||
&& retainedDelivery
|
||||
&& deliveryConsistent
|
||||
);
|
||||
const recoverySelected = recoveryAuthority !== null && browserLineageSelected;
|
||||
const recoveredBrowserSelected = Boolean(
|
||||
recoveredBrowserAuthority
|
||||
&& browserLineageSelected
|
||||
);
|
||||
const selected = normallySelected || recoverySelected || recoveredBrowserSelected;
|
||||
const activation = activationValid
|
||||
? {
|
||||
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
|
||||
maxActive: maxActive as number,
|
||||
selected,
|
||||
controllable: Boolean(attested && rawActivation?.controllable),
|
||||
controllable: Boolean(
|
||||
!recoveryOwnsPresentation && attested && rawActivation?.controllable,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
|
||||
const delivery = selected ? browserDelivery(candidateDelivery) : null;
|
||||
const delivery = selected && (
|
||||
dataAuthoritative || recoverySelected || recoveredBrowserSelected
|
||||
)
|
||||
? retainedDelivery
|
||||
: null;
|
||||
const label = stream.label?.trim() || sourceId;
|
||||
|
||||
return [{
|
||||
@@ -272,12 +491,23 @@ export function xgridsK1ObservationSources(
|
||||
description: "Видеоканал, опубликованный активным device-плагином",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: cameraAvailability(state, stream, selected, delivery, attested),
|
||||
availability: cameraAvailability(
|
||||
state,
|
||||
stream,
|
||||
selected,
|
||||
delivery,
|
||||
attested,
|
||||
recoverySelected || recoveredBrowserSelected,
|
||||
exactCameraMediaReady,
|
||||
),
|
||||
transport: delivery ? "websocket" : "other",
|
||||
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
|
||||
previewUrl: null,
|
||||
delivery,
|
||||
activation,
|
||||
presentationLease: (recoverySelected || recoveredBrowserSelected) && delivery
|
||||
? presentationLease
|
||||
: null,
|
||||
provider,
|
||||
binding,
|
||||
capabilities: {
|
||||
|
||||
@@ -6,6 +6,15 @@ export interface OperatorIntentToken extends RuntimeGenerationToken {
|
||||
readonly intentGeneration: number;
|
||||
}
|
||||
|
||||
export function isSnapshotRuntimeCurrent(
|
||||
expectedSnapshotRuntimeId: string,
|
||||
currentSnapshotRuntimeId: string | null | undefined,
|
||||
): boolean {
|
||||
const expected = expectedSnapshotRuntimeId.trim();
|
||||
const current = currentSnapshotRuntimeId?.trim() ?? "";
|
||||
return Boolean(expected && current && expected === current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates asynchronous UI work across both plugin activation changes and
|
||||
* successive explicit operator intents.
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import type {
|
||||
OperatorPresenceConfirmation,
|
||||
XgridsAcquisition,
|
||||
XgridsApplicationControlSession,
|
||||
XgridsConnectionMode,
|
||||
XgridsK1State,
|
||||
} from "./api";
|
||||
import { currentAppliedConnectionTopology, isSoftwareCommandedAcquisition } from "./lifecycle";
|
||||
|
||||
export interface PhysicalConfirmationChecks {
|
||||
operatorPresent: boolean;
|
||||
ownerControlledDevice: boolean;
|
||||
lixelgoClosed: boolean;
|
||||
batteryStorageConfirmed: boolean;
|
||||
expectedPhysicalStateConfirmed: boolean;
|
||||
}
|
||||
|
||||
type CompletedPhysicalConfirmationChecks = {
|
||||
[Key in keyof PhysicalConfirmationChecks]: true;
|
||||
};
|
||||
|
||||
export type K1PhysicalConfirmationKind = "prepare" | "start" | "stop";
|
||||
|
||||
/**
|
||||
* Semantic state that authorises one physical command confirmation.
|
||||
*
|
||||
* Timestamps, the polling snapshot revision and ConnectionSupervisor.revision
|
||||
* are deliberately absent: the latter is an observation counter and advances
|
||||
* even when a probe confirms the same semantic route. A read-only refresh must
|
||||
* not invalidate an operator confirmation. Every field below, however, changes
|
||||
* the identity, route, CAS authority, acquisition or runtime state of the
|
||||
* command and therefore closes an already-open modal.
|
||||
*/
|
||||
export interface K1PhysicalCommandFence {
|
||||
kind: K1PhysicalConfirmationKind;
|
||||
commandDeviceId: string;
|
||||
commandProjectName: string;
|
||||
acquisitionId: string;
|
||||
runtimeId: string | null;
|
||||
runtimePhase: string | null;
|
||||
runtimeSourceMode: string | null;
|
||||
selectedDeviceId: string | null;
|
||||
deviceRefId: string | null;
|
||||
deviceSessionId: string | null;
|
||||
deviceSessionDeviceId: string | null;
|
||||
deviceSessionConnectivity: string | null;
|
||||
connectionIntentId: string | null;
|
||||
requestedConnectionMode: XgridsConnectionMode | null;
|
||||
expectedDeviceId: string | null;
|
||||
deviceNetworkState: string | null;
|
||||
deviceNetworkIntentId: string | null;
|
||||
transportRef: string | null;
|
||||
connectionMode: XgridsConnectionMode | null;
|
||||
targetIpv4: string | null;
|
||||
targetPort: number | null;
|
||||
hostPathEpoch: number | null;
|
||||
hostPathAvailable: boolean | null;
|
||||
deviceIdentityState: string | null;
|
||||
deviceIdentityId: string | null;
|
||||
controlPlaneState: string | null;
|
||||
controlPlaneSessionId: string | null;
|
||||
dataPlaneState: string | null;
|
||||
dataPlaneSessionId: string | null;
|
||||
leaseState: string | null;
|
||||
leaseGeneration: number | null;
|
||||
controlAllowed: boolean | null;
|
||||
acquisitionStartAllowed: boolean | null;
|
||||
dataIngestAuthoritative: boolean | null;
|
||||
controlSessionGeneration: number | null;
|
||||
controlStateRevision: number | null;
|
||||
controlState: string | null;
|
||||
controlSocketOpen: boolean | null;
|
||||
verifiedControlSessionId: string | null;
|
||||
controlProofRevision: number | null;
|
||||
controlProofFresh: boolean | null;
|
||||
deviceReportedState: string | null;
|
||||
deviceProjectBound: boolean | null;
|
||||
deviceInitReady: boolean | null;
|
||||
acquisitionState: string | null;
|
||||
acquisitionStateRevision: number | null;
|
||||
acquisitionDeviceId: string | null;
|
||||
acquisitionDeviceSessionId: string | null;
|
||||
acquisitionControlMode: string | null;
|
||||
}
|
||||
|
||||
export interface K1PhysicalCommandTarget {
|
||||
deviceId: string;
|
||||
connection: string;
|
||||
projectName: string;
|
||||
acquisitionId: string;
|
||||
deviceState: string;
|
||||
fence: K1PhysicalCommandFence;
|
||||
}
|
||||
|
||||
export interface K1PhysicalCommandCheckpoint {
|
||||
readonly kind: K1PhysicalConfirmationKind;
|
||||
readonly target: Readonly<Omit<K1PhysicalCommandTarget, "fence">>;
|
||||
readonly fence: Readonly<K1PhysicalCommandFence>;
|
||||
readonly fenceKey: string;
|
||||
}
|
||||
|
||||
export interface K1PhysicalCommandConfirmationPayload {
|
||||
readonly physicalAcceptance: Readonly<OperatorPresenceConfirmation>;
|
||||
readonly checkpoint: K1PhysicalCommandCheckpoint;
|
||||
}
|
||||
|
||||
export function emptyPhysicalConfirmationChecks(): PhysicalConfirmationChecks {
|
||||
return {
|
||||
operatorPresent: false,
|
||||
ownerControlledDevice: false,
|
||||
lixelgoClosed: false,
|
||||
batteryStorageConfirmed: false,
|
||||
expectedPhysicalStateConfirmed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function physicalConfirmationComplete(
|
||||
checks: PhysicalConfirmationChecks,
|
||||
): checks is CompletedPhysicalConfirmationChecks {
|
||||
return (
|
||||
checks.operatorPresent
|
||||
&& checks.ownerControlledDevice
|
||||
&& checks.lixelgoClosed
|
||||
&& checks.batteryStorageConfirmed
|
||||
&& checks.expectedPhysicalStateConfirmed
|
||||
);
|
||||
}
|
||||
|
||||
export function operatorPresenceConfirmation(
|
||||
checks: PhysicalConfirmationChecks,
|
||||
): OperatorPresenceConfirmation | null {
|
||||
if (!physicalConfirmationComplete(checks)) return null;
|
||||
return {
|
||||
operator_present: checks.operatorPresent,
|
||||
owner_controlled_device: checks.ownerControlledDevice,
|
||||
lixelgo_closed: checks.lixelgoClosed,
|
||||
battery_storage_confirmed: checks.batteryStorageConfirmed,
|
||||
expected_physical_state_confirmed: checks.expectedPhysicalStateConfirmed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One deliberate click on the local K1 START/STOP action is the operator's
|
||||
* physical acceptance. The backend still validates the exact control CAS,
|
||||
* live DeviceInfo/status binding and command ledger before a vendor write;
|
||||
* this helper only removes the redundant five-checkbox modal.
|
||||
*/
|
||||
export function operatorActionPhysicalAcceptance(): OperatorPresenceConfirmation {
|
||||
return {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function recordValue(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): unknown {
|
||||
return record?.[key];
|
||||
}
|
||||
|
||||
function trimmed(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function boolean(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function commandFence(
|
||||
kind: K1PhysicalConfirmationKind,
|
||||
state: XgridsK1State | null | undefined,
|
||||
target: Omit<K1PhysicalCommandTarget, "fence">,
|
||||
): K1PhysicalCommandFence {
|
||||
const supervisor = state?.connection_supervisor;
|
||||
const deviceNetwork = supervisor?.observed.device_network;
|
||||
const hostPath = supervisor?.observed.host_path;
|
||||
const deviceIdentity = supervisor?.observed.device_identity;
|
||||
const controlPlane = supervisor?.observed.control_plane;
|
||||
const dataPlane = supervisor?.observed.data_plane;
|
||||
const control = state?.application_control_session;
|
||||
const verifiedControl = control?.verified_control;
|
||||
const acquisition = state?.acquisition;
|
||||
|
||||
return {
|
||||
kind,
|
||||
commandDeviceId: target.deviceId,
|
||||
commandProjectName: target.projectName,
|
||||
acquisitionId: target.acquisitionId,
|
||||
runtimeId: trimmed(state?.snapshot_runtime_id),
|
||||
runtimePhase: trimmed(state?.phase),
|
||||
runtimeSourceMode: trimmed(state?.source_mode),
|
||||
selectedDeviceId: trimmed(state?.selected_device_id),
|
||||
deviceRefId: trimmed(state?.device_ref?.device_id),
|
||||
deviceSessionId: trimmed(state?.device_session?.device_session_id),
|
||||
deviceSessionDeviceId: trimmed(state?.device_session?.device_id),
|
||||
deviceSessionConnectivity: trimmed(state?.device_session?.connectivity),
|
||||
connectionIntentId: trimmed(supervisor?.intent?.intent_id),
|
||||
requestedConnectionMode: supervisor?.intent?.requested_mode ?? null,
|
||||
expectedDeviceId: trimmed(supervisor?.intent?.expected_device_id),
|
||||
deviceNetworkState: trimmed(deviceNetwork?.state),
|
||||
deviceNetworkIntentId: trimmed(deviceNetwork?.intent_id),
|
||||
transportRef: trimmed(deviceNetwork?.transport_ref),
|
||||
connectionMode: deviceNetwork?.connection_mode ?? null,
|
||||
targetIpv4: trimmed(deviceNetwork?.target?.ipv4),
|
||||
targetPort: integer(deviceNetwork?.target?.port),
|
||||
hostPathEpoch: integer(hostPath?.epoch),
|
||||
hostPathAvailable: boolean(hostPath?.available),
|
||||
deviceIdentityState: trimmed(deviceIdentity?.state),
|
||||
deviceIdentityId: trimmed(deviceIdentity?.logical_device_id),
|
||||
controlPlaneState: trimmed(controlPlane?.state),
|
||||
controlPlaneSessionId: trimmed(controlPlane?.session_id),
|
||||
dataPlaneState: trimmed(dataPlane?.state),
|
||||
dataPlaneSessionId: trimmed(dataPlane?.session_id),
|
||||
leaseState: trimmed(supervisor?.lease.state),
|
||||
leaseGeneration: integer(supervisor?.lease.generation),
|
||||
controlAllowed: boolean(supervisor?.authority.control_allowed),
|
||||
acquisitionStartAllowed: boolean(supervisor?.authority.acquisition_start_allowed),
|
||||
dataIngestAuthoritative: boolean(supervisor?.authority.data_ingest_authoritative),
|
||||
controlSessionGeneration: integer(control?.session_generation),
|
||||
controlStateRevision: integer(control?.state_revision),
|
||||
controlState: trimmed(control?.state),
|
||||
controlSocketOpen: boolean(control?.control_socket_open),
|
||||
verifiedControlSessionId: trimmed(verifiedControl?.control_session_id),
|
||||
controlProofRevision: integer(verifiedControl?.control_proof_revision),
|
||||
controlProofFresh: boolean(verifiedControl?.control_proof_fresh),
|
||||
deviceReportedState: trimmed(recordValue(control?.transport, "latest_device_session_state")),
|
||||
deviceProjectBound: boolean(recordValue(control?.transport, "latest_device_project_bound")),
|
||||
deviceInitReady: boolean(recordValue(control?.transport, "latest_device_init_ready")),
|
||||
acquisitionState: trimmed(acquisition?.state),
|
||||
acquisitionStateRevision: integer(acquisition?.state_revision),
|
||||
acquisitionDeviceId: trimmed(acquisition?.device_id),
|
||||
acquisitionDeviceSessionId: trimmed(acquisition?.device_session_id),
|
||||
acquisitionControlMode: trimmed(acquisition?.control_mode),
|
||||
};
|
||||
}
|
||||
|
||||
export function physicalCommandFenceKey(
|
||||
kind: K1PhysicalConfirmationKind,
|
||||
target: K1PhysicalCommandTarget,
|
||||
): string {
|
||||
// Both values are included. This makes a mismatched component kind fail
|
||||
// closed even if a caller accidentally supplies a target built for another
|
||||
// physical command.
|
||||
return JSON.stringify([
|
||||
kind,
|
||||
target.deviceId,
|
||||
target.connection,
|
||||
target.projectName,
|
||||
target.acquisitionId,
|
||||
target.deviceState,
|
||||
target.fence,
|
||||
]);
|
||||
}
|
||||
|
||||
export function createPhysicalCommandCheckpoint(
|
||||
kind: K1PhysicalConfirmationKind,
|
||||
target: K1PhysicalCommandTarget,
|
||||
): K1PhysicalCommandCheckpoint {
|
||||
const fence = Object.freeze({ ...target.fence });
|
||||
const targetSnapshot = Object.freeze({
|
||||
deviceId: target.deviceId,
|
||||
connection: target.connection,
|
||||
projectName: target.projectName,
|
||||
acquisitionId: target.acquisitionId,
|
||||
deviceState: target.deviceState,
|
||||
});
|
||||
return Object.freeze({
|
||||
kind,
|
||||
target: targetSnapshot,
|
||||
fence,
|
||||
fenceKey: physicalCommandFenceKey(kind, target),
|
||||
});
|
||||
}
|
||||
|
||||
export function physicalCommandCheckpointMatches(
|
||||
checkpoint: K1PhysicalCommandCheckpoint,
|
||||
kind: K1PhysicalConfirmationKind,
|
||||
target: K1PhysicalCommandTarget,
|
||||
): boolean {
|
||||
return checkpoint.kind === kind
|
||||
&& checkpoint.fence.kind === kind
|
||||
&& target.fence.kind === kind
|
||||
&& checkpoint.fenceKey === physicalCommandFenceKey(kind, target);
|
||||
}
|
||||
|
||||
function targetWithFence(
|
||||
kind: K1PhysicalConfirmationKind,
|
||||
state: XgridsK1State | null | undefined,
|
||||
target: Omit<K1PhysicalCommandTarget, "fence">,
|
||||
): K1PhysicalCommandTarget {
|
||||
return {
|
||||
...target,
|
||||
fence: commandFence(kind, state, target),
|
||||
};
|
||||
}
|
||||
|
||||
function exactReadyState(
|
||||
control: XgridsApplicationControlSession,
|
||||
): string | null {
|
||||
const deviceState = trimmed(recordValue(control.transport, "latest_device_session_state"));
|
||||
const projectBound = recordValue(control.transport, "latest_device_project_bound");
|
||||
const initReady = recordValue(control.transport, "latest_device_init_ready");
|
||||
if (deviceState !== "ready" || projectBound !== true || initReady !== false) return null;
|
||||
return "READY · проект привязан · инициализация не запущена";
|
||||
}
|
||||
|
||||
function exactScanningState(
|
||||
control: XgridsApplicationControlSession | null | undefined,
|
||||
): string {
|
||||
const deviceState = trimmed(recordValue(control?.transport, "latest_device_session_state"));
|
||||
const projectBound = recordValue(control?.transport, "latest_device_project_bound");
|
||||
const initReady = recordValue(control?.transport, "latest_device_init_ready");
|
||||
if (deviceState === "scanning" && projectBound === true && initReady === true) {
|
||||
return "SCANNING · проект привязан · инициализация завершена";
|
||||
}
|
||||
return deviceState
|
||||
? `${deviceState.toUpperCase()} · последнее подтверждённое состояние K1`
|
||||
: "Состояние K1 не подтверждено текущим управляющим каналом";
|
||||
}
|
||||
|
||||
function exactConnection(
|
||||
control: XgridsApplicationControlSession | null | undefined,
|
||||
): string | null {
|
||||
const verified = control?.verified_control;
|
||||
if (!verified) return null;
|
||||
return `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`;
|
||||
}
|
||||
|
||||
function acquisitionProject(acquisition: XgridsAcquisition): string {
|
||||
return trimmed(acquisition.project_name) ?? "Проект без опубликованного имени";
|
||||
}
|
||||
|
||||
export function preparedStartTarget(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): K1PhysicalCommandTarget | null {
|
||||
const acquisition = state?.acquisition;
|
||||
const control = state?.application_control_session;
|
||||
const verified = control?.verified_control;
|
||||
const topology = currentAppliedConnectionTopology(state);
|
||||
const supervisor = state?.connection_supervisor;
|
||||
const deviceNetwork = supervisor?.observed.device_network;
|
||||
const hostPath = supervisor?.observed.host_path;
|
||||
const readyState = control ? exactReadyState(control) : null;
|
||||
if (
|
||||
!acquisition
|
||||
|| acquisition.state !== "prepared"
|
||||
|| acquisition.control_mode !== "plugin-commanded"
|
||||
|| !control
|
||||
|| control.state !== "project-ready"
|
||||
|| control.can_start !== true
|
||||
|| !verified
|
||||
|| verified.control_proof_fresh !== true
|
||||
|| verified.logical_device_id !== acquisition.device_id
|
||||
|| verified.compatibility_profile_id !== acquisition.compatibility_profile_id
|
||||
|| supervisor?.authority.acquisition_start_allowed !== true
|
||||
|| verified.intent_id !== supervisor.intent?.intent_id
|
||||
|| verified.host_path_epoch !== hostPath?.epoch
|
||||
|| verified.transport_ref !== deviceNetwork?.transport_ref
|
||||
|| verified.connection_mode !== deviceNetwork?.connection_mode
|
||||
|| verified.target_ipv4 !== deviceNetwork?.target?.ipv4
|
||||
|| verified.target_port !== deviceNetwork?.target?.port
|
||||
|| topology?.status !== "active"
|
||||
|| topology.connectionMode !== verified.connection_mode
|
||||
|| topology.endpoint !== verified.target_ipv4
|
||||
|| state?.connection_lifecycle?.ready_to_start !== true
|
||||
|| !readyState
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return targetWithFence("start", state, {
|
||||
deviceId: verified.logical_device_id,
|
||||
connection: `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`,
|
||||
projectName: acquisitionProject(acquisition),
|
||||
acquisitionId: acquisition.acquisition_id,
|
||||
deviceState: readyState,
|
||||
});
|
||||
}
|
||||
|
||||
export function preparationTarget(
|
||||
state: XgridsK1State | null | undefined,
|
||||
projectName: string,
|
||||
): K1PhysicalCommandTarget | null {
|
||||
const topology = currentAppliedConnectionTopology(state);
|
||||
const supervisor = state?.connection_supervisor;
|
||||
const deviceNetwork = supervisor?.observed.device_network;
|
||||
if (
|
||||
!topology
|
||||
|| !supervisor
|
||||
|| topology.status === "configured-offline"
|
||||
|| !deviceNetwork?.transport_ref
|
||||
|| !deviceNetwork.target
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const logicalDeviceId = supervisor.observed.device_identity.logical_device_id
|
||||
?? supervisor.intent?.expected_device_id
|
||||
?? deviceNetwork.transport_ref;
|
||||
return targetWithFence("prepare", state, {
|
||||
deviceId: logicalDeviceId,
|
||||
connection: `${topology.connectionMode} · ${deviceNetwork.target.ipv4}:${deviceNetwork.target.port}`,
|
||||
projectName: projectName.trim(),
|
||||
acquisitionId: "Будет создана подготовительным этапом; START пока недоступен",
|
||||
deviceState: "Подготовка не начата · физический START не разрешён",
|
||||
});
|
||||
}
|
||||
|
||||
export function activeStopTarget(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): K1PhysicalCommandTarget | null {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || !isSoftwareCommandedAcquisition(state)) return null;
|
||||
const control = state?.application_control_session;
|
||||
return targetWithFence("stop", state, {
|
||||
deviceId: acquisition.device_id,
|
||||
connection: exactConnection(control) ?? "Текущий управляющий канал не подтверждён",
|
||||
projectName: acquisitionProject(acquisition),
|
||||
acquisitionId: acquisition.acquisition_id,
|
||||
deviceState: exactScanningState(control),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,245 @@
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
||||
import type { XgridsK1Metrics } from "./api";
|
||||
import type {
|
||||
XgridsConnectionPolicyAction,
|
||||
XgridsK1Metrics,
|
||||
XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
connectionPolicyAllows,
|
||||
connectionPolicyDecision,
|
||||
} from "./lifecycle";
|
||||
|
||||
const connectionPolicyReasonCopy: Record<string, string> = {
|
||||
"connection-supervisor-closed": "Контур связи K1 закрыт.",
|
||||
"supervisor-action-not-allowed": "Текущая связь с K1 не подтверждает право на эту физическую команду.",
|
||||
"network-provision-operation-active": "Предыдущая сетевая операция K1 ещё не завершена.",
|
||||
"acquisition-active": "Сетевой режим K1 нельзя менять во время активного приёма.",
|
||||
"acquisition-cleanup-pending": "Локальный приём K1 ещё завершает очистку ресурсов.",
|
||||
"local-runtime-active": "Локальный исполнительный контур K1 ещё активен.",
|
||||
"control-session-not-admissible-for-network-change": "Текущая управляющая сессия K1 ещё не допускает смену сети.",
|
||||
"network-mutation-reconciliation-required": "Результат предыдущей сетевой записи K1 не подтверждён.",
|
||||
"network-mutation-ledger-corrupt": "Журнал сетевых изменений K1 повреждён.",
|
||||
"fresh-ble-candidate-required": "Для этого действия нужен K1 из нового Bluetooth-поиска.",
|
||||
"retained-recovery-context-unavailable": "Сохранённый Bluetooth-контекст текущего K1 больше недоступен.",
|
||||
"fresh-candidate-supersedes-retained-recovery": "K1 снова виден в свежем поиске; используйте новый найденный экземпляр.",
|
||||
"reconciliation-target-not-observed": "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
||||
"reconciliation-target-not-retained": "Текущий серверный Bluetooth-контекст относится не к тому K1, для которого не завершена сетевая операция.",
|
||||
"current-device-context-unavailable": "Текущий K1 не подтверждён в оперативном контексте этого процесса.",
|
||||
"durable-recovery-target-unavailable": "В сохранённом серверном состоянии нет одной точной пары K1 и режима для проверки после перезапуска.",
|
||||
"fresh-candidate-supersedes-durable-recovery": "Нужный K1 снова найден свежим Bluetooth-поиском; сервер требует проверить именно свежий экземпляр.",
|
||||
"retained-recovery-supersedes-durable-recovery": "Сервер хранит более свежий контекст текущего K1.",
|
||||
"configured-endpoint-unavailable": "Нет подтверждённого сохранённого адреса K1 для безопасной проверки.",
|
||||
"configured-endpoint-probe-lifecycle-busy": "Контур подключения K1 занят другой операцией.",
|
||||
"configured-endpoint-changed-during-probe": "Адрес K1 изменился во время проверки; результат отброшен.",
|
||||
"configured-endpoint-probe-failed": "Маршрут и управляющий endpoint K1 не удалось проверить.",
|
||||
"host-path-unavailable": "На этом компьютере не подтверждён сетевой путь до K1.",
|
||||
"host-route-not-direct": "Маршрут до K1 проходит не через ожидаемую локальную сеть.",
|
||||
"endpoint-not-reachable": "Управляющий endpoint K1 сейчас недоступен.",
|
||||
"device-identity-unverified": "Идентичность подключённого K1 ещё не подтверждена.",
|
||||
"device-identity-stale": "Подтверждение идентичности K1 устарело.",
|
||||
"device-identity-mismatch": "Подключённое устройство не совпало с выбранным K1.",
|
||||
"device-identity-pin-store-corrupt": "Хранилище привязки устройства повреждено.",
|
||||
"network-provisioning-idempotency-unavailable": "Журнал сетевых намерений K1 недоступен.",
|
||||
"network-provisioning-idempotency-corrupt": "Журнал сетевых намерений K1 повреждён.",
|
||||
"network-provisioning-idempotency-invalid": "Журнал сетевых намерений K1 не прошёл проверку.",
|
||||
"network-provisioning-idempotency-operation-mismatch": "Текущая сетевая операция не совпала с сохранённым намерением.",
|
||||
"semantic-topology-store-corrupt": "Сохранённая топология K1 повреждена.",
|
||||
"control-plane-not-healthy": "Управляющий канал K1 не подтверждён.",
|
||||
"connection-lease-not-reachable": "Текущая сессия связи K1 больше не подтверждена.",
|
||||
"data-plane-stalled": "Поток данных K1 перестал обновляться.",
|
||||
"data-plane-lost": "Поток данных K1 потерян.",
|
||||
"physical-command-reconciliation-required": "Результат предыдущей физической команды K1 не подтверждён.",
|
||||
"physical-device-already-active": "Последнее подтверждённое состояние K1 — активное сканирование.",
|
||||
"physical-command-recovery-target-unavailable": "Журнал не содержит точную Bluetooth-цель для восстановления K1.",
|
||||
"physical-command-recovery-target-not-observed": "Исходный K1 пока не найден в свежем Bluetooth-поиске.",
|
||||
"physical-command-recovery-target-not-retained": "Сохранённый Bluetooth-контекст относится не к исходному K1.",
|
||||
"physical-command-recovery-target-mismatch": "Выбрано другое устройство или другой способ связи, чем в незавершённой физической сессии.",
|
||||
"physical-command-ledger-corrupt": "Журнал физических команд K1 повреждён.",
|
||||
"physical-command-ledger-unavailable": "Журнал физических команд K1 недоступен.",
|
||||
"physical-control-authority-unavailable": "Управляющая связь не позволяет безопасно отправить физический STOP.",
|
||||
"ble-runtime-restart-required": "BLE-контур требует контролируемого перезапуска.",
|
||||
"ble-runtime-cleanup-pending": "BLE-контур завершает предыдущую операцию.",
|
||||
"ble-runtime-busy": "BLE-контур занят другой операцией.",
|
||||
"k1-lifecycle-process-lease-network-owned": "Сетевой переход K1 ещё владеет исполнительным контуром.",
|
||||
"k1-lifecycle-process-lease-control-owned": "Управляющая сессия K1 ещё владеет исполнительным контуром.",
|
||||
"local-acquisition-receiver-not-active": "Активного локального приёмника сейчас нет.",
|
||||
"physical-stop-is-authoritative": "Доступна подтверждённая физическая остановка K1; локальная очистка не должна её подменять.",
|
||||
"action-not-implemented": "Это действие не реализовано и не может быть выполнено.",
|
||||
"connection-reconfiguration-lifecycle-busy": "Другое действие подключения ещё не завершено.",
|
||||
"connection-reconfiguration-bridge-only": "Это действие доступно только для подключения Bridge.",
|
||||
"connection-reconfiguration-current-device-unavailable": "Нет точной привязки текущего устройства для изменения сети.",
|
||||
"connection-reconfiguration-active": "Сначала завершите или отмените текущий выбор устройства или сети.",
|
||||
"connection-reconfiguration-required-device-not-observed": "Исходное устройство не найдено в текущем Bluetooth-поиске.",
|
||||
"connection-reconfiguration-not-active": "Активного изменения устройства или сети уже нет.",
|
||||
"connection-reconfiguration-process-lease-busy": "Другой локальный процесс ещё управляет подключением устройства.",
|
||||
"connection-reconfiguration-acquisition-changed": "Состояние приёма изменилось во время подготовки подключения.",
|
||||
"connection-reconfiguration-revision-conflict": "Выбор устройства или сети уже изменился в другой вкладке.",
|
||||
"connection-reconfiguration-binding-conflict": "Активное подключение изменилось до выполнения действия.",
|
||||
"connection-reconfiguration-fresh-scan-required": "Для этого действия нужен новый Bluetooth-поиск.",
|
||||
"connection-reconfiguration-discovery-conflict": "Результаты Bluetooth-поиска относятся к предыдущему действию.",
|
||||
"connection-reconfiguration-target-mismatch": "Изменение сети разрешено только для исходного устройства.",
|
||||
"acquisition-start-operation-active": "Запуск приёма ещё не завершён.",
|
||||
"control-session-state-unsafe": "Управляющий диалог ещё не достиг безопасного состояния ожидания.",
|
||||
};
|
||||
|
||||
const connectionPolicyNextActionCopy: Record<string, string> = {
|
||||
"wait-for-operation": "Дождитесь завершения текущей операции и обновите состояние.",
|
||||
"diagnose-network-ledger": "Не отправляйте новые команды и проверьте журнал сетевой операции.",
|
||||
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
|
||||
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
|
||||
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
|
||||
"observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
|
||||
"observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
|
||||
"observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
||||
"recover-current-device-network": "Нажмите «Подключиться заново».",
|
||||
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
|
||||
"probe-endpoint": "Дождитесь обновления подключения K1.",
|
||||
"verify-control-device-info": "Подключитесь заново к выбранному K1.",
|
||||
"select-connection-intent": "Выберите способ подключения и заново подтвердите текущий K1.",
|
||||
"start-acquisition": "Повторно откройте финальное подтверждение физического START.",
|
||||
"stop-acquisition": "Остановите K1 через подтверждённую физическую остановку.",
|
||||
"stop-local-receiver": "Завершите локальный приём; физическое состояние K1 проверьте вручную.",
|
||||
"retire-unavailable-physical-target": "Явно исключите недоступный прежний K1 перед новым выбором.",
|
||||
"manual-recovery-required": "Автоматически безопасного продолжения нет; проверьте состояние K1 вручную.",
|
||||
"cancel-reconfiguration": "Отмените текущий выбор и вернитесь к обычному восстановлению подключения.",
|
||||
};
|
||||
|
||||
export interface ConnectionPolicyOperatorGuidance {
|
||||
reason: string;
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
const connectionModeSelectionReasonCopy: Record<string, string> = {
|
||||
"connection-mode-selection-physical-state-unsafe":
|
||||
"Предыдущая физическая команда K1 осталась без подтверждённого результата. Поэтому способ подключения пока нельзя изменить.",
|
||||
"connection-mode-selection-control-state-unsafe":
|
||||
"Текущий управляющий процесс K1 ещё не завершён. После его завершения способ подключения снова станет доступен.",
|
||||
"connection-mode-selection-lifecycle-busy":
|
||||
"Текущее действие подключения ещё завершается. После него способ подключения снова станет доступен.",
|
||||
"connection-reconfiguration-active":
|
||||
"Сначала завершите или отмените текущий выбор устройства или сети.",
|
||||
};
|
||||
|
||||
const physicalRetirementReasonCopy: Record<string, string> = {
|
||||
"physical-command-retirement-operation-conflict":
|
||||
"Сейчас завершается другая операция с физическим состоянием устройства. Дождитесь её завершения и обновите состояние.",
|
||||
"physical-command-retirement-state-unsafe":
|
||||
"Состояние предыдущей физической команды изменилось. Обновите состояние перед новым выбором устройства.",
|
||||
"physical-command-retirement-not-required":
|
||||
"Предыдущая физическая команда уже разрешена или больше не удерживает выбор устройства. Обновите состояние и продолжите обычное подключение.",
|
||||
"physical-command-target-retired":
|
||||
"Предыдущее устройство уже выведено из текущего контура. Можно сразу выполнить новый явный Bluetooth-поиск.",
|
||||
"network-provision-operation-active":
|
||||
"Сейчас завершается подключение устройства к сети. Дождитесь результата перед выбором другого устройства.",
|
||||
"connection-reconfiguration-active":
|
||||
"Сначала завершите или отмените текущее изменение устройства или сети.",
|
||||
"control-local-retirement-pending":
|
||||
"Управляющая сессия ещё освобождает локальные ресурсы. Дождитесь завершения и обновите состояние.",
|
||||
"acquisition-active":
|
||||
"Сканирование ещё активно. Сначала остановите его и дождитесь подтверждённого завершения.",
|
||||
"acquisition-cleanup-pending":
|
||||
"Локальный приём ещё освобождает ресурсы после остановки. Дождитесь завершения.",
|
||||
"acquisition-start-operation-active":
|
||||
"Запуск сканирования ещё не завершён. Дождитесь его результата перед сменой устройства.",
|
||||
"acquisition-stop-operation-active":
|
||||
"Остановка сканирования ещё не завершена. Дождитесь её результата перед сменой устройства.",
|
||||
"local-runtime-active":
|
||||
"Локальный поток устройства ещё активен или завершается. Дождитесь перехода в состояние ожидания.",
|
||||
"control-session-state-unsafe":
|
||||
"Управляющая сессия устройства ещё не завершена. Дождитесь её закрытия и обновите состояние.",
|
||||
"ble-runtime-busy":
|
||||
"Bluetooth занят другой операцией устройства. Дождитесь её завершения; новый поиск автоматически не запустится.",
|
||||
"ble-runtime-cleanup-pending":
|
||||
"Bluetooth ещё завершает предыдущую операцию. Дождитесь освобождения соединения и обновите состояние.",
|
||||
"ble-runtime-restart-required":
|
||||
"Локальный Bluetooth-контур требует контролируемого перезапуска. Команды устройству не отправлялись.",
|
||||
"k1-lifecycle-process-lease-active":
|
||||
"Другой локальный процесс ещё завершает действие с устройством. Дождитесь его завершения и обновите состояние.",
|
||||
"physical-command-ledger-corrupt":
|
||||
"Журнал физической команды повреждён. Не выбирайте другое устройство до проверки журнала.",
|
||||
};
|
||||
|
||||
const physicalReopenReasonCopy: Record<string, string> = {
|
||||
...physicalRetirementReasonCopy,
|
||||
"physical-command-reconciliation-reopen-not-required":
|
||||
"Это устройство больше не требует возврата из предыдущего выбора. Обновите состояние и продолжите обычное подключение.",
|
||||
"physical-command-reconciliation-reopen-target-not-observed":
|
||||
"Предыдущее устройство не найдено в последнем Bluetooth-поиске. Обновите поиск после проверки питания K1.",
|
||||
"physical-command-reconciliation-reopen-target-not-connectable":
|
||||
"Предыдущее устройство найдено, но сейчас не принимает Bluetooth-подключение. Проверьте питание K1 и повторите явный поиск.",
|
||||
"physical-command-reconciliation-reopen-candidate-ambiguous":
|
||||
"Последний Bluetooth-поиск не подтвердил один точный экземпляр предыдущего K1. Повторите поиск рядом только с нужным устройством.",
|
||||
"physical-command-reconciliation-reopen-operation-conflict":
|
||||
"Другая операция уже меняет локальное состояние предыдущего устройства. Дождитесь её завершения и обновите поиск.",
|
||||
"device-calibration-read-active":
|
||||
"Сейчас читается калибровка устройства. Дождитесь завершения проверки перед повторным использованием K1.",
|
||||
};
|
||||
|
||||
/** Human explanation for a backend-disabled topology selector. */
|
||||
export function connectionModeSelectionGuidance(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
const selection = state?.connection_lifecycle?.mode_selection;
|
||||
if (!selection) return null;
|
||||
if (selection?.allowed === true) return null;
|
||||
const reasonCode = selection?.reason_codes.find((code) => code.trim().length > 0);
|
||||
return reasonCode
|
||||
? connectionModeSelectionReasonCopy[reasonCode]
|
||||
?? "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
|
||||
: "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
|
||||
}
|
||||
|
||||
/** Human explanation for a currently unavailable local-only device escape. */
|
||||
export function physicalRetirementGuidance(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
const retirement = (
|
||||
state?.physical_command
|
||||
?? state?.application_control_session?.physical_command
|
||||
?? null
|
||||
)?.operator_retirement;
|
||||
if (!retirement || retirement.allowed === true) return null;
|
||||
const reasonCode = retirement.reason_codes.find((code) => code.trim().length > 0);
|
||||
return reasonCode
|
||||
? physicalRetirementReasonCopy[reasonCode]
|
||||
?? "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции."
|
||||
: "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции.";
|
||||
}
|
||||
|
||||
/** Human explanation for a backend-disabled exact retired-device reopen. */
|
||||
export function physicalReopenGuidance(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
const reopen = state?.physical_command?.operator_reconciliation_reopen;
|
||||
if (!reopen || reopen.allowed === true) return null;
|
||||
const reasonCode = reopen.reason_codes.find((code) => code.trim().length > 0);
|
||||
return reasonCode
|
||||
? physicalReopenReasonCopy[reasonCode]
|
||||
?? "Повторная проверка предыдущего K1 сейчас небезопасна. Дождитесь завершения текущей операции и обновите поиск."
|
||||
: "Повторная проверка предыдущего K1 сейчас небезопасна. Обновите поиск после завершения текущей операции.";
|
||||
}
|
||||
|
||||
export function connectionPolicyOperatorGuidance(
|
||||
state: XgridsK1State | null | undefined,
|
||||
action: XgridsConnectionPolicyAction,
|
||||
): ConnectionPolicyOperatorGuidance | null {
|
||||
if (connectionPolicyAllows(state, action)) return null;
|
||||
const decision = connectionPolicyDecision(state, action);
|
||||
const reasonCode = decision?.reason_codes.find((code) => code.trim().length > 0);
|
||||
const recommendedAction = state?.connection_policy?.recommended_action?.trim();
|
||||
return {
|
||||
reason: reasonCode
|
||||
? connectionPolicyReasonCopy[reasonCode]
|
||||
?? "Система временно запретила действие до восстановления подтверждённого состояния."
|
||||
: "Подтверждённая политика действия ещё не получена.",
|
||||
nextAction: recommendedAction
|
||||
? connectionPolicyNextActionCopy[recommendedAction]
|
||||
?? "Обновите состояние подключения и следуйте рекомендованному безопасному действию."
|
||||
: "Обновите состояние подключения перед новым действием.",
|
||||
};
|
||||
}
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
idle: "Ожидание",
|
||||
|
||||
@@ -30,3 +30,24 @@ export function validateProjectName(input: string): ProjectNameValidation {
|
||||
}
|
||||
return { value, error: null };
|
||||
}
|
||||
|
||||
export function projectNameAfterConnectionModeSelection(
|
||||
preparedProjectName: string | null | undefined,
|
||||
): string {
|
||||
return preparedProjectName ?? "";
|
||||
}
|
||||
|
||||
export function shouldHydratePreparedProject({
|
||||
acquisitionId,
|
||||
hydratedAcquisitionId,
|
||||
modeSwitchRequired,
|
||||
}: {
|
||||
acquisitionId: string | null;
|
||||
hydratedAcquisitionId: string | null;
|
||||
modeSwitchRequired: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
acquisitionId
|
||||
&& !(hydratedAcquisitionId === acquisitionId && modeSwitchRequired),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,19 @@ import {
|
||||
type MissionRuntimeState,
|
||||
} from "@mission-core/plugin-sdk";
|
||||
import {
|
||||
activeConnectionEndpointLabel,
|
||||
canonicalDeviceConnectivity,
|
||||
confirmedRuntimeSourceMode,
|
||||
effectiveAcquisition,
|
||||
hasAuthoritativeData,
|
||||
hasControlAuthority,
|
||||
normalizeRuntimePhase,
|
||||
spatialSourceId,
|
||||
} from "./lifecycle";
|
||||
import {
|
||||
activeStreamRecoveryOwnsPresentationDecision,
|
||||
activeStreamRecoveryPresentationAuthority,
|
||||
} from "./activeStreamRecovery";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
|
||||
@@ -23,13 +31,21 @@ export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
||||
|
||||
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
|
||||
|
||||
function normalizeState(
|
||||
controller: XgridsK1Controller,
|
||||
export function normalizeXgridsK1MissionState(
|
||||
controller: Pick<XgridsK1Controller, "state">,
|
||||
activeModel: DeviceModelDefinition,
|
||||
): MissionRuntimeState | null {
|
||||
const state = controller.state;
|
||||
if (!state) return null;
|
||||
const metrics = state.metrics;
|
||||
const controlAuthoritative = hasControlAuthority(state);
|
||||
const recoveryPresentationAuthority = activeStreamRecoveryPresentationAuthority(state);
|
||||
const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
|
||||
const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
|
||||
const recoveryPresentationAuthoritative = recoveryPresentationAuthority !== null;
|
||||
const replayAuthoritative = state.source_mode === "replay";
|
||||
const metrics = replayAuthoritative || dataAuthoritative
|
||||
? state.metrics
|
||||
: undefined;
|
||||
const telemetry = deviceTelemetry(metrics);
|
||||
const deviceRef = state.device_ref;
|
||||
const deviceSession = state.device_session;
|
||||
@@ -43,13 +59,13 @@ function normalizeState(
|
||||
return {
|
||||
phase: normalizeRuntimePhase(state),
|
||||
message: localizeRuntimeMessage(state.message),
|
||||
activeDevice: deviceRef
|
||||
activeDevice: deviceRef && controlAuthoritative
|
||||
? {
|
||||
pluginId: xgridsK1Manifest.metadata.id,
|
||||
modelId: deviceRef.model_id || activeModel.id,
|
||||
displayName: activeModel.displayName,
|
||||
instanceId: deviceRef.device_id,
|
||||
endpointLabel: state.k1_ip,
|
||||
endpointLabel: activeConnectionEndpointLabel(state),
|
||||
}
|
||||
: null,
|
||||
deviceSession: deviceSession
|
||||
@@ -57,7 +73,7 @@ function normalizeState(
|
||||
sessionId: deviceSession.device_session_id,
|
||||
deviceId: deviceSession.device_id,
|
||||
compatibilityProfileId: deviceSession.compatibility_profile_id,
|
||||
connectivity: deviceSession.connectivity,
|
||||
connectivity: canonicalDeviceConnectivity(state),
|
||||
}
|
||||
: null,
|
||||
acquisition: acquisition
|
||||
@@ -80,7 +96,9 @@ function normalizeState(
|
||||
stageCode: operation.stage_code,
|
||||
messageCode: operation.message_code,
|
||||
})),
|
||||
spatialSource: sourceUrl && resolvedSpatialSourceId
|
||||
spatialSource: sourceUrl
|
||||
&& resolvedSpatialSourceId
|
||||
&& (replayAuthoritative || dataAuthoritative || recoveryPresentationAuthoritative)
|
||||
? {
|
||||
id: resolvedSpatialSourceId,
|
||||
url: sourceUrl,
|
||||
@@ -102,7 +120,9 @@ function normalizeState(
|
||||
range: null,
|
||||
},
|
||||
viewerSettings: state.viewer_settings,
|
||||
sourceMode: confirmedRuntimeSourceMode(state),
|
||||
sourceMode: recoveryPresentationAuthoritative
|
||||
? "live"
|
||||
: confirmedRuntimeSourceMode(state),
|
||||
metrics: {
|
||||
publishedFrameCount: (
|
||||
Number.isSafeInteger(metrics?.pcl_frames) &&
|
||||
@@ -140,10 +160,13 @@ export function XgridsK1RuntimeProvider({
|
||||
const inheritedRuntime = useMissionRuntime();
|
||||
const controller = useXgridsK1Runtime(active);
|
||||
const missionRuntime: MissionRuntimeController = {
|
||||
state: activeModel ? normalizeState(controller, activeModel) : null,
|
||||
state: activeModel
|
||||
? normalizeXgridsK1MissionState(controller, activeModel)
|
||||
: null,
|
||||
backendStatus: controller.backendStatus,
|
||||
pendingAction: controller.pendingAction,
|
||||
refresh: controller.refresh,
|
||||
refresh: () => controller.refresh().then(() => undefined),
|
||||
resetConnectionScenario: controller.resetConnectionScenario,
|
||||
updateViewerSettings: controller.updateViewerSettings,
|
||||
setObservationSourceActive: controller.setObservationSourceActive,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,66 @@ function deviceSessionScope(state: XgridsK1State): string | null {
|
||||
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
|
||||
}
|
||||
|
||||
interface RuntimeSnapshotStamp {
|
||||
startedAtMonotonicNs: bigint | null;
|
||||
startedAtEpochMs: number | null;
|
||||
runtimeId: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
function monotonicNanoseconds(value: string | null | undefined): bigint | null {
|
||||
if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return null;
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeSnapshotStamp(state: XgridsK1State): RuntimeSnapshotStamp | null {
|
||||
const startedAt = state.snapshot_runtime_started_at_utc;
|
||||
const startedAtMonotonicNs = monotonicNanoseconds(
|
||||
state.snapshot_runtime_started_monotonic_ns,
|
||||
);
|
||||
const runtimeId = state.snapshot_runtime_id;
|
||||
const revision = monotonicInteger(state.snapshot_revision);
|
||||
if (
|
||||
typeof runtimeId !== "string"
|
||||
|| !runtimeId.trim()
|
||||
|| revision === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const parsedEpochMs = typeof startedAt === "string" ? Date.parse(startedAt) : Number.NaN;
|
||||
const startedAtEpochMs = Number.isFinite(parsedEpochMs) ? parsedEpochMs : null;
|
||||
if (startedAtMonotonicNs === null && startedAtEpochMs === null) return null;
|
||||
return { startedAtMonotonicNs, startedAtEpochMs, runtimeId, revision };
|
||||
}
|
||||
|
||||
function stampedSnapshotIsAtLeastAsNew(
|
||||
current: RuntimeSnapshotStamp,
|
||||
incoming: RuntimeSnapshotStamp,
|
||||
): boolean {
|
||||
if (incoming.runtimeId === current.runtimeId) {
|
||||
return incoming.revision >= current.revision;
|
||||
}
|
||||
if (incoming.startedAtMonotonicNs !== null || current.startedAtMonotonicNs !== null) {
|
||||
if (incoming.startedAtMonotonicNs === null) return false;
|
||||
if (current.startedAtMonotonicNs === null) return true;
|
||||
return incoming.startedAtMonotonicNs > current.startedAtMonotonicNs;
|
||||
}
|
||||
if (
|
||||
incoming.startedAtEpochMs !== null
|
||||
&& current.startedAtEpochMs !== null
|
||||
&& incoming.startedAtEpochMs !== current.startedAtEpochMs
|
||||
) {
|
||||
return incoming.startedAtEpochMs > current.startedAtEpochMs;
|
||||
}
|
||||
// Legacy UTC-only process identities with equal timestamps cannot be
|
||||
// ordered safely. Keep the already accepted authority.
|
||||
return false;
|
||||
}
|
||||
|
||||
function cameraSnapshotIsAtLeastAsNew(
|
||||
current: XgridsCameraPreviewState,
|
||||
incoming: XgridsCameraPreviewState,
|
||||
@@ -43,6 +103,17 @@ export function selectMonotonicXgridsState(
|
||||
current: XgridsK1State | null,
|
||||
incoming: XgridsK1State,
|
||||
): XgridsK1State {
|
||||
if (!current) return incoming;
|
||||
const currentStamp = runtimeSnapshotStamp(current);
|
||||
const incomingStamp = runtimeSnapshotStamp(incoming);
|
||||
if (currentStamp || incomingStamp) {
|
||||
if (!currentStamp) return incoming;
|
||||
if (!incomingStamp) return current;
|
||||
return stampedSnapshotIsAtLeastAsNew(currentStamp, incomingStamp)
|
||||
? incoming
|
||||
: current;
|
||||
}
|
||||
|
||||
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
/* All selectors below are scoped to the XGRIDS frontend contribution. */
|
||||
.xgrids-k1-plugin {
|
||||
container: xgrids-k1 / inline-size;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.device-workspace__grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
|
||||
max-width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@@ -11,6 +29,7 @@
|
||||
.device-workspace__side {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -18,9 +37,114 @@
|
||||
.status-panel,
|
||||
.latency-panel,
|
||||
.session-panel {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
/* Every layout hop between the plugin root and the canonical controls must be
|
||||
shrinkable. A single auto min-size in this chain lets topology/status text
|
||||
establish a wider intrinsic track and paint the provisioning job over the
|
||||
acquisition job even though the outer grid itself uses minmax(0, 1fr). */
|
||||
.workspace-lead,
|
||||
.metrics-grid,
|
||||
.error-banner,
|
||||
.wizard-list,
|
||||
.wizard-step,
|
||||
.field-stack,
|
||||
.session-form,
|
||||
.scan-configuration-grid,
|
||||
.device-list,
|
||||
.device-row,
|
||||
.diagnostics-grid,
|
||||
.detail-list {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.metrics-grid > *,
|
||||
.device-workspace__grid > *,
|
||||
.device-workspace__side > *,
|
||||
.scan-configuration-grid > *,
|
||||
.diagnostics-grid > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.error-banner > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-banner__copy {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.error-banner--compact {
|
||||
align-items: start;
|
||||
padding-block: 0.7rem;
|
||||
}
|
||||
|
||||
.error-banner__recovery-actions {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.error-banner__details {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.error-banner__details summary {
|
||||
width: fit-content;
|
||||
color: var(--nodedc-text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-lead__status,
|
||||
.workspace-lead__status > span,
|
||||
.panel-heading > div,
|
||||
.panel-heading h2,
|
||||
.wizard-step__content,
|
||||
.wizard-step__content > header,
|
||||
.wizard-step__content > header h3,
|
||||
.connection-summary span,
|
||||
.nodedc-field__description,
|
||||
.empty-device-list,
|
||||
.retained-recovery-target small,
|
||||
.session-footer p {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-lead__status > span,
|
||||
.panel-heading h2,
|
||||
.wizard-step__content > header h3,
|
||||
.connection-summary span,
|
||||
.nodedc-field__description,
|
||||
.empty-device-list,
|
||||
.retained-recovery-target small,
|
||||
.session-footer p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workspace-lead p,
|
||||
.error-banner p,
|
||||
.step-copy,
|
||||
.safety-note,
|
||||
.live-instruction {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
container: k1-connection-panel / inline-size;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
container: k1-session-panel / inline-size;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
@@ -58,12 +182,54 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error-banner__diagnostic {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0.45rem 0 0;
|
||||
}
|
||||
|
||||
.error-banner__diagnostic > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-banner__diagnostic dt,
|
||||
.error-banner__diagnostic dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error-banner__diagnostic dt {
|
||||
color: var(--nodedc-text-tertiary);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error-banner__diagnostic dd {
|
||||
margin-top: 0.12rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.error-banner__actions > .nodedc-button {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wizard-list {
|
||||
display: grid;
|
||||
margin-top: 1.4rem;
|
||||
@@ -71,6 +237,8 @@
|
||||
|
||||
.configuration-anchor {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1.2rem;
|
||||
border-radius: 0.95rem;
|
||||
@@ -81,6 +249,35 @@
|
||||
.configuration-anchor .nodedc-select-anchor,
|
||||
.configuration-field .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.connection-topology-summary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.connection-topology-summary .connection-summary {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-summary__value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-summary__value strong {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
@@ -219,19 +416,26 @@
|
||||
}
|
||||
|
||||
.device-row__identity strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.69rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-row code,
|
||||
.detail-row code {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-row__signal {
|
||||
@@ -242,7 +446,7 @@
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.device-row[data-compatible="true"] .device-row__signal {
|
||||
.device-row[data-likely-k1="true"] .device-row__signal {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
@@ -260,14 +464,81 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.retained-recovery-target {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.42rem;
|
||||
margin-top: 0.62rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.78rem;
|
||||
}
|
||||
|
||||
.retained-recovery-target > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.retained-recovery-target span,
|
||||
.retained-recovery-target small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.retained-recovery-target code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.field-stack,
|
||||
.session-form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.password-field-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.connection-recovery-choice {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 0.65rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-recovery-choice > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.connection-recovery-choice > .safety-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-recovery-choice--retirement {
|
||||
border-color: rgb(var(--nodedc-danger-rgb) / 0.24);
|
||||
}
|
||||
|
||||
.connection-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
@@ -283,6 +554,8 @@
|
||||
}
|
||||
|
||||
.connection-summary strong {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -296,6 +569,61 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__state {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__state--static {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.active-stream-recovery__state > .nodedc-activity-indicator {
|
||||
margin-top: 0.12rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__copy strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.active-stream-recovery__copy span,
|
||||
.active-stream-recovery__copy small,
|
||||
.active-stream-recovery__actions p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.active-stream-recovery__copy small {
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.active-stream-recovery__actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.scan-configuration-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -375,11 +703,12 @@
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.inline-state {
|
||||
@@ -449,25 +778,106 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
/* Keep both jobs usable before admitting the split composition: the
|
||||
provisioning column retains 32 rem and the acquisition column 38 rem.
|
||||
Below their combined working width the panels stack instead of squeezing
|
||||
and letting intrinsic text paint into the neighbouring surface. */
|
||||
@container xgrids-k1 (min-width: 78rem) {
|
||||
.xgrids-k1-plugin .device-workspace__grid {
|
||||
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||
grid-template-columns: minmax(32rem, 0.8fr) minmax(38rem, 1.2fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.xgrids-k1-plugin .device-workspace__grid {
|
||||
grid-template-columns: 1fr;
|
||||
@container k1-connection-panel (max-width: 48rem) {
|
||||
.xgrids-k1-plugin .panel-heading,
|
||||
.xgrids-k1-plugin .wizard-step__content > header {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .panel-heading > div,
|
||||
.xgrids-k1-plugin .wizard-step__content > header h3 {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .panel-heading > .nodedc-status,
|
||||
.xgrids-k1-plugin .wizard-step__content > header > .nodedc-status,
|
||||
.xgrids-k1-plugin .connection-summary__value > .nodedc-status,
|
||||
.xgrids-k1-plugin .retained-recovery-target .nodedc-status {
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .connection-summary,
|
||||
.xgrids-k1-plugin .connection-summary--topology,
|
||||
.xgrids-k1-plugin .connection-summary__value {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .connection-summary__value {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .connection-summary strong {
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-row__action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-row__name {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-row__name small {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .retained-recovery-target > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
@container xgrids-k1 (max-width: 65rem) {
|
||||
.xgrids-k1-plugin .diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@container k1-session-panel (max-width: 48rem) {
|
||||
.xgrids-k1-plugin .panel-heading {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .panel-heading > div {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .panel-heading > .nodedc-status {
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .scan-configuration-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -480,12 +890,33 @@
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-footer,
|
||||
.xgrids-k1-plugin .error-banner {
|
||||
.xgrids-k1-plugin .session-footer {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@container xgrids-k1 (max-width: 48rem) {
|
||||
.xgrids-k1-plugin .workspace-lead,
|
||||
.xgrids-k1-plugin .panel-heading,
|
||||
.xgrids-k1-plugin .wizard-step__content > header {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .workspace-lead > div,
|
||||
.xgrids-k1-plugin .workspace-lead__status,
|
||||
.xgrids-k1-plugin .panel-heading > div,
|
||||
.xgrids-k1-plugin .wizard-step__content > header h3 {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .workspace-lead__status {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -493,12 +924,57 @@
|
||||
|
||||
.xgrids-k1-plugin .error-banner__actions {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-row__action {
|
||||
.xgrids-k1-plugin .error-banner__diagnostic {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .retained-recovery-target > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@container xgrids-k1 (max-width: 32rem) {
|
||||
.xgrids-k1-plugin .wizard-step {
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr);
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .wizard-step__rail span {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .detail-row dd {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner__dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner__actions {
|
||||
grid-column: 1;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +993,57 @@
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls--recovery {
|
||||
display: grid;
|
||||
min-width: min(46rem, 100%);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__compact-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery__compact-heading > span:first-child {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.14em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact {
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact .active-stream-recovery__state {
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact .active-stream-recovery__actions {
|
||||
max-width: 15rem;
|
||||
grid-template-columns: auto;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact .active-stream-recovery__actions p {
|
||||
font-size: 0.5rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase {
|
||||
display: flex;
|
||||
min-width: 11rem;
|
||||
@@ -547,16 +1074,6 @@
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__spinner {
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
flex: 0 0 0.82rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: xgrids-k1-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
@@ -598,8 +1115,32 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes xgrids-k1-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
.xgrids-k1-spatial-controls__action-label {
|
||||
display: block;
|
||||
width: 9.75rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.08;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label--local {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
.connection-action-progress {
|
||||
display: flex;
|
||||
min-height: 2.75rem;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.connection-action-progress strong {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@@ -612,4 +1153,16 @@
|
||||
.xgrids-k1-spatial-controls__error small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact .active-stream-recovery__actions {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.active-stream-recovery--compact .active-stream-recovery__actions p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import CoreWLAN
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import Security
|
||||
|
||||
private let keychainService = "NODEDC Mission Core Host Wi-Fi Profiles"
|
||||
@@ -14,6 +15,8 @@ private struct HostWifiRequest: Decodable {
|
||||
let password: String?
|
||||
let scanTimeoutSeconds: Double?
|
||||
let credentialSourceID: String?
|
||||
let interfaceName: String?
|
||||
let continuityKeyHex: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case action
|
||||
@@ -22,6 +25,8 @@ private struct HostWifiRequest: Decodable {
|
||||
case password
|
||||
case scanTimeoutSeconds = "scan_timeout_seconds"
|
||||
case credentialSourceID = "credential_source_id"
|
||||
case interfaceName = "interface_name"
|
||||
case continuityKeyHex = "continuity_key_hex"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +65,9 @@ private struct HostWifiResponse: Encodable {
|
||||
let scanAttemptCount: Int?
|
||||
let scanElapsedMilliseconds: Int?
|
||||
let credentialSource: String?
|
||||
let wifiInterface: Bool?
|
||||
let associationIdentity: String?
|
||||
let associationEvidence: String?
|
||||
let reasonCode: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -73,6 +81,9 @@ private struct HostWifiResponse: Encodable {
|
||||
case scanAttemptCount = "scan_attempt_count"
|
||||
case scanElapsedMilliseconds = "scan_elapsed_ms"
|
||||
case credentialSource = "credential_source"
|
||||
case wifiInterface = "wifi_interface"
|
||||
case associationIdentity = "association_identity"
|
||||
case associationEvidence = "association_evidence"
|
||||
case reasonCode = "reason_code"
|
||||
}
|
||||
}
|
||||
@@ -88,6 +99,9 @@ private func emit(
|
||||
scanAttemptCount: Int? = nil,
|
||||
scanElapsedMilliseconds: Int? = nil,
|
||||
credentialSource: String? = nil,
|
||||
wifiInterface: Bool? = nil,
|
||||
associationIdentity: String? = nil,
|
||||
associationEvidence: String? = nil,
|
||||
reasonCode: String? = nil,
|
||||
exitCode: Int32
|
||||
) -> Never {
|
||||
@@ -102,6 +116,9 @@ private func emit(
|
||||
scanAttemptCount: scanAttemptCount,
|
||||
scanElapsedMilliseconds: scanElapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
wifiInterface: wifiInterface,
|
||||
associationIdentity: associationIdentity,
|
||||
associationEvidence: associationEvidence,
|
||||
reasonCode: reasonCode
|
||||
)
|
||||
if let data = try? JSONEncoder().encode(response) {
|
||||
@@ -148,10 +165,75 @@ private func materialKeychainQuery(sourceID: String) -> [String: Any] {
|
||||
return keychainQuery(service: credentialMaterialKeychainService, account: sourceID)
|
||||
}
|
||||
|
||||
private func loadProfile(profileID: String) throws -> StoredProfile {
|
||||
private func nonInteractiveAuthenticationContext() -> LAContext {
|
||||
let context = LAContext()
|
||||
context.interactionNotAllowed = true
|
||||
return context
|
||||
}
|
||||
|
||||
private func keychainItemExists(service: String, account: String) throws -> Bool {
|
||||
var query = keychainQuery(service: service, account: account)
|
||||
query[kSecReturnAttributes as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
// Preflight is deliberately non-interactive. Authorization prompts belong
|
||||
// only to an explicit enrollment/migration step, never to a K1 network
|
||||
// mutation that has already been admitted by the browser.
|
||||
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
if status == errSecItemNotFound {
|
||||
return false
|
||||
}
|
||||
throw NSError(domain: "HostWifiKeychainMetadata", code: Int(status))
|
||||
}
|
||||
|
||||
private func keychainReasonCode(_ error: Error, missing: String) -> String {
|
||||
let status = OSStatus((error as NSError).code)
|
||||
switch status {
|
||||
case errSecItemNotFound:
|
||||
return missing
|
||||
case errSecInteractionNotAllowed:
|
||||
return "keychain-authorization-required"
|
||||
case errSecAuthFailed:
|
||||
return "keychain-authorization-denied"
|
||||
case errSecUserCanceled:
|
||||
return "keychain-authorization-cancelled"
|
||||
default:
|
||||
return "keychain-access-failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func coreWLANReasonCode(_ error: Error) -> String {
|
||||
let nsError = error as NSError
|
||||
guard nsError.domain == CWErrorDomain else {
|
||||
return "corewlan-error"
|
||||
}
|
||||
// Stable CWErr values from Apple's CoreWLANTypes contract. Export only a
|
||||
// reviewed failure class; NSError descriptions may contain host details.
|
||||
switch nsError.code {
|
||||
case -3930: // kCWOperationNotPermittedErr
|
||||
return "corewlan-authorization-denied"
|
||||
case -3905, -3925: // kCWTimeoutErr, kCWSupplicantTimeoutErr
|
||||
return "host-wifi-operation-timeout"
|
||||
default:
|
||||
return "corewlan-error"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfile(
|
||||
profileID: String,
|
||||
interactionAllowed: Bool = true
|
||||
) throws -> StoredProfile {
|
||||
var query = profileKeychainQuery(profileID: profileID)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
if !interactionAllowed {
|
||||
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
||||
}
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
@@ -191,10 +273,16 @@ private func storeProfile(profileID: String, profile: StoredProfile) throws {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCredentialMaterial(sourceID: String) throws -> StoredCredentialMaterial {
|
||||
private func loadCredentialMaterial(
|
||||
sourceID: String,
|
||||
interactionAllowed: Bool = true
|
||||
) throws -> StoredCredentialMaterial {
|
||||
var query = materialKeychainQuery(sourceID: sourceID)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
if !interactionAllowed {
|
||||
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
||||
}
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
@@ -237,25 +325,6 @@ private func storeCredentialMaterial(
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSystemWiFiProfile(ssid: String, ssidData: Data) -> StoredProfile? {
|
||||
var password: NSString?
|
||||
let status = CWKeychainFindWiFiPassword(
|
||||
CWKeychainDomain.user,
|
||||
ssidData,
|
||||
&password
|
||||
)
|
||||
guard status == errSecSuccess, let password else {
|
||||
return nil
|
||||
}
|
||||
let profile = StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: password as String,
|
||||
credentialSource: "system-wifi-keychain"
|
||||
)
|
||||
return profileIsValid(profile) ? profile : nil
|
||||
}
|
||||
|
||||
private struct TargetedScanResult {
|
||||
let network: CWNetwork?
|
||||
let attemptCount: Int
|
||||
@@ -298,26 +367,57 @@ private func scanForExpectedNetwork(
|
||||
}
|
||||
}
|
||||
|
||||
private func promptForDevicePassword(ssid: String) -> String? {
|
||||
let application = NSApplication.shared
|
||||
application.setActivationPolicy(.accessory)
|
||||
|
||||
let passwordField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
|
||||
passwordField.placeholderString = "Пароль точки доступа K1"
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = "Первое подключение к \(ssid)"
|
||||
alert.informativeText = "macOS не нашла локальный профиль этой точки доступа. Если credential вам неизвестен, нажмите «Отмена» и выполните авторизованный импорт device-профиля LixelGO. Введённое значение будет сохранено только в Keychain этого Mac и не попадёт в браузер, API, журнал или evidence Mission Core."
|
||||
alert.accessoryView = passwordField
|
||||
alert.addButton(withTitle: "Подключиться")
|
||||
alert.addButton(withTitle: "Отмена")
|
||||
|
||||
application.activate(ignoringOtherApps: true)
|
||||
guard alert.runModal() == .alertFirstButtonReturn else {
|
||||
private func decodeContinuityKey(_ value: String) -> Data? {
|
||||
let bytes = Array(value.utf8)
|
||||
guard bytes.count == 64 else {
|
||||
return nil
|
||||
}
|
||||
return passwordField.stringValue
|
||||
func nibble(_ byte: UInt8) -> UInt8? {
|
||||
switch byte {
|
||||
case 48 ... 57:
|
||||
return byte - 48
|
||||
case 97 ... 102:
|
||||
return byte - 87
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var decoded = Data(capacity: 32)
|
||||
for offset in stride(from: 0, to: bytes.count, by: 2) {
|
||||
guard let high = nibble(bytes[offset]), let low = nibble(bytes[offset + 1]) else {
|
||||
return nil
|
||||
}
|
||||
decoded.append((high << 4) | low)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func appendLengthPrefixed(_ value: String, to material: inout Data) {
|
||||
let data = Data(value.utf8)
|
||||
var length = UInt32(data.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { bytes in
|
||||
material.append(contentsOf: bytes)
|
||||
}
|
||||
material.append(data)
|
||||
}
|
||||
|
||||
private func associationIdentity(
|
||||
continuityKey: Data,
|
||||
interfaceName: String,
|
||||
bssid: String
|
||||
) -> String {
|
||||
// BSSID is the association identity. SSID visibility is permission- and
|
||||
// timing-dependent on macOS, so folding it into this token would rotate a
|
||||
// healthy binding when the same AP alternates between `ssid+bssid` and
|
||||
// `bssid-only` evidence.
|
||||
var material = Data("mission-core/host-wifi-association/v2".utf8)
|
||||
appendLengthPrefixed(interfaceName, to: &material)
|
||||
appendLengthPrefixed(bssid.lowercased(), to: &material)
|
||||
let digest = HMAC<SHA256>.authenticationCode(
|
||||
for: material,
|
||||
using: SymmetricKey(data: continuityKey)
|
||||
)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private let input = FileHandle.standardInput.readDataToEndOfFile()
|
||||
@@ -335,6 +435,75 @@ do {
|
||||
emit(ok: false, reasonCode: "profile-id-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
if request.action == "inspect-association" {
|
||||
guard let interfaceName = request.interfaceName,
|
||||
(1 ... 32).contains(interfaceName.count),
|
||||
interfaceName.allSatisfy({
|
||||
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
||||
}),
|
||||
let continuityKeyHex = request.continuityKeyHex,
|
||||
let continuityKey = decodeContinuityKey(continuityKeyHex)
|
||||
else {
|
||||
emit(ok: false, reasonCode: "association-inspection-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
guard let interface = CWWiFiClient.shared().interface(withName: interfaceName) else {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
wifiInterface: false,
|
||||
associationIdentity: associationIdentity(
|
||||
continuityKey: continuityKey,
|
||||
interfaceName: interfaceName,
|
||||
bssid: "not-wifi-interface"
|
||||
),
|
||||
associationEvidence: "not-wifi",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
guard interface.powerOn(), interface.serviceActive() else {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
wifiInterface: true,
|
||||
associationEvidence: "unavailable",
|
||||
reasonCode: "wifi-interface-inactive",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
let currentSSID = interface.ssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let currentBSSID = interface.bssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let currentBSSID, !currentBSSID.isEmpty else {
|
||||
// SSID alone is not an exact association identity: two APs may use
|
||||
// the same network name. Returning no digest forces the Python
|
||||
// caller to rotate its fail-closed continuity token.
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
wifiInterface: true,
|
||||
associationEvidence: "unavailable",
|
||||
reasonCode: "association-identity-unavailable",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
wifiInterface: true,
|
||||
associationIdentity: associationIdentity(
|
||||
continuityKey: continuityKey,
|
||||
interfaceName: interfaceName,
|
||||
bssid: currentBSSID
|
||||
),
|
||||
associationEvidence: (
|
||||
currentSSID == nil || currentSSID?.isEmpty == true
|
||||
? "bssid-only"
|
||||
: "ssid+bssid"
|
||||
),
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
|
||||
if request.action == "store-profile" {
|
||||
guard let ssid = request.ssid, let password = request.password else {
|
||||
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
|
||||
@@ -364,20 +533,27 @@ do {
|
||||
|
||||
if request.action == "check-credential-material" {
|
||||
do {
|
||||
_ = try loadCredentialMaterial(sourceID: request.profileID)
|
||||
let available = try keychainItemExists(
|
||||
service: credentialMaterialKeychainService,
|
||||
account: request.profileID
|
||||
)
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
credentialSource: "exact-firmware-profile",
|
||||
profileAvailable: available,
|
||||
credentialSource: available ? "exact-firmware-profile" : nil,
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
exitCode: 0
|
||||
reasonCode: keychainReasonCode(
|
||||
error,
|
||||
missing: "credential-source-unavailable"
|
||||
),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -395,55 +571,77 @@ do {
|
||||
emit(ok: false, reasonCode: "credential-source-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
let material: StoredCredentialMaterial
|
||||
do {
|
||||
material = try loadCredentialMaterial(sourceID: sourceID)
|
||||
let profileAvailable = try keychainItemExists(
|
||||
service: keychainService,
|
||||
account: request.profileID
|
||||
)
|
||||
if profileAvailable {
|
||||
let profile = try loadProfile(
|
||||
profileID: request.profileID,
|
||||
interactionAllowed: false
|
||||
)
|
||||
guard profile.ssid == ssid else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
guard profile.credentialSource == "exact-firmware-profile" else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "profile-credential-source-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
profileEnrolled: false,
|
||||
credentialSource: "exact-firmware-profile",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "credential-source-unavailable",
|
||||
exitCode: 0
|
||||
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
|
||||
let material: StoredCredentialMaterial
|
||||
do {
|
||||
material = try loadCredentialMaterial(
|
||||
sourceID: sourceID,
|
||||
interactionAllowed: false
|
||||
)
|
||||
} catch {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: keychainReasonCode(
|
||||
error,
|
||||
missing: "credential-source-unavailable"
|
||||
),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let existing = try loadProfile(profileID: request.profileID)
|
||||
guard existing.ssid == ssid else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
if existing.password == material.password,
|
||||
existing.credentialSource != "exact-firmware-profile" {
|
||||
try storeProfile(
|
||||
profileID: request.profileID,
|
||||
profile: StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: existing.password,
|
||||
credentialSource: "exact-firmware-profile"
|
||||
)
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
profileEnrolled: false,
|
||||
credentialSource: existing.password == material.password
|
||||
? "exact-firmware-profile"
|
||||
: (existing.credentialSource ?? "mission-core-keychain"),
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
try storeProfile(
|
||||
profileID: request.profileID,
|
||||
profile: StoredProfile(
|
||||
@@ -461,39 +659,82 @@ do {
|
||||
credentialSource: "exact-firmware-profile",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if request.action == "check-profile" {
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
profile = try loadProfile(profileID: request.profileID)
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
if let expectedSSID = request.ssid, profile.ssid != expectedSSID {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
profileEnrolled: false,
|
||||
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
|
||||
guard request.action == "associate" || request.action == "scan-profile" else {
|
||||
if request.action == "check-profile" {
|
||||
guard let expectedSSID = request.ssid,
|
||||
let expectedSSIDData = expectedSSID.data(using: .utf8),
|
||||
(1 ... 32).contains(expectedSSIDData.count)
|
||||
else {
|
||||
emit(ok: false, reasonCode: "ssid-invalid", exitCode: 1)
|
||||
}
|
||||
do {
|
||||
let available = try keychainItemExists(
|
||||
service: keychainService,
|
||||
account: request.profileID
|
||||
)
|
||||
if !available {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
let profile = try loadProfile(
|
||||
profileID: request.profileID,
|
||||
interactionAllowed: false
|
||||
)
|
||||
guard profile.ssid == expectedSSID else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
guard profile.credentialSource == "exact-firmware-profile" else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
reasonCode: "profile-credential-source-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
credentialSource: "exact-firmware-profile",
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard request.action == "associate"
|
||||
|| request.action == "associate-ephemeral"
|
||||
|| request.action == "scan-profile"
|
||||
else {
|
||||
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
||||
}
|
||||
guard let interface = CWWiFiClient.shared().interface() else {
|
||||
@@ -528,7 +769,7 @@ do {
|
||||
)
|
||||
}
|
||||
|
||||
guard request.action == "associate" else {
|
||||
guard request.action == "associate" || request.action == "associate-ephemeral" else {
|
||||
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
||||
}
|
||||
guard let expectedSSID = request.ssid,
|
||||
@@ -554,83 +795,76 @@ do {
|
||||
)
|
||||
}
|
||||
|
||||
var profileEnrolled = false
|
||||
var profileNeedsStore = false
|
||||
let credentialSource: String
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
profile = try loadProfile(profileID: request.profileID)
|
||||
credentialSource = profile.credentialSource ?? "mission-core-keychain"
|
||||
} catch {
|
||||
if let systemProfile = loadSystemWiFiProfile(
|
||||
ssid: expectedSSID,
|
||||
ssidData: expectedSSIDData
|
||||
) {
|
||||
profile = systemProfile
|
||||
credentialSource = "system-wifi-keychain"
|
||||
profileNeedsStore = true
|
||||
} else {
|
||||
guard let password = promptForDevicePassword(ssid: expectedSSID) else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "credential-entry-cancelled",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
guard let passwordData = password.data(using: .utf8),
|
||||
(1 ... 64).contains(passwordData.count)
|
||||
else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "credential-invalid",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
profile = StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: expectedSSID,
|
||||
password: password,
|
||||
credentialSource: "native-secure-prompt"
|
||||
let associationPassword: String
|
||||
if request.action == "associate-ephemeral" {
|
||||
guard let password = request.password,
|
||||
let passwordData = password.data(using: .utf8),
|
||||
(1 ... 64).contains(passwordData.count)
|
||||
else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "credential-missing",
|
||||
exitCode: 1
|
||||
)
|
||||
credentialSource = "native-secure-prompt"
|
||||
profileNeedsStore = true
|
||||
}
|
||||
}
|
||||
guard profile.ssid == expectedSSID else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
credentialSource = "operation-memory"
|
||||
associationPassword = password
|
||||
} else {
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
// The AP write has already happened. A prepared-host Quick action must
|
||||
// never trigger a Keychain authorization sheet at this stage.
|
||||
profile = try loadProfile(
|
||||
profileID: request.profileID,
|
||||
interactionAllowed: false
|
||||
)
|
||||
guard profile.credentialSource == "exact-firmware-profile" else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "profile-credential-source-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
credentialSource = "exact-firmware-profile"
|
||||
} catch {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
guard profile.ssid == expectedSSID else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
associationPassword = profile.password
|
||||
}
|
||||
|
||||
if interface.ssid() == profile.ssid {
|
||||
if profileNeedsStore {
|
||||
try storeProfile(profileID: request.profileID, profile: profile)
|
||||
profileEnrolled = true
|
||||
}
|
||||
if interface.ssid() == expectedSSID {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
alreadyAssociated: true,
|
||||
profileEnrolled: profileEnrolled,
|
||||
profileEnrolled: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
try interface.associate(to: network, password: profile.password)
|
||||
if profileNeedsStore {
|
||||
try storeProfile(profileID: request.profileID, profile: profile)
|
||||
profileEnrolled = true
|
||||
}
|
||||
try interface.associate(to: network, password: associationPassword)
|
||||
// CoreWLAN's synchronous association call throws on failure. Reading the
|
||||
// current SSID again would require Location authorization on recent macOS
|
||||
// versions and could turn a successful association into a false negative.
|
||||
@@ -638,12 +872,12 @@ do {
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
alreadyAssociated: false,
|
||||
profileEnrolled: profileEnrolled,
|
||||
profileEnrolled: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
emit(ok: false, reasonCode: "corewlan-error", exitCode: 1)
|
||||
emit(ok: false, reasonCode: coreWLANReasonCode(error), exitCode: 1)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"kind": "DevicePlugin",
|
||||
"metadata": {
|
||||
"id": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.5",
|
||||
"displayName": "XGRIDS K1 Integration"
|
||||
},
|
||||
"spec": {
|
||||
@@ -37,11 +37,15 @@
|
||||
{ "id": "sensor.catalog.read", "mutating": false, "secretFields": [] },
|
||||
{ "id": "calibration.device-snapshot.read", "mutating": false, "secretFields": [] },
|
||||
{ "id": "network.provision", "mutating": true, "secretFields": ["password"] },
|
||||
{ "id": "connection.mode.select", "mutating": true, "secretFields": [] },
|
||||
{ "id": "connection.reconfigure.prepare", "mutating": true, "secretFields": [] },
|
||||
{ "id": "connection.verify", "mutating": false, "secretFields": [] },
|
||||
{ "id": "connection.endpoint-probe", "mutating": false, "secretFields": [] },
|
||||
{ "id": "acquisition.prepare", "mutating": true, "secretFields": [] },
|
||||
{ "id": "acquisition.start", "mutating": true, "secretFields": [] },
|
||||
{ "id": "acquisition.stop", "mutating": true, "secretFields": [] },
|
||||
{ "id": "acquisition.abort", "mutating": true, "secretFields": [] },
|
||||
{ "id": "acquisition.force-finish-local", "mutating": true, "secretFields": [] },
|
||||
{ "id": "acquisition.state.read", "mutating": false, "secretFields": [] },
|
||||
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
|
||||
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
|
||||
@@ -54,7 +58,10 @@
|
||||
{ "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.session.open", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.workspace.enter", "mutating": true, "secretFields": [] },
|
||||
{ "id": "application-control.session.close", "mutating": true, "secretFields": [] }
|
||||
{ "id": "application-control.session.close", "mutating": true, "secretFields": [] },
|
||||
{ "id": "physical-command.reconcile", "mutating": true, "secretFields": [] },
|
||||
{ "id": "physical-command.retire-unavailable", "mutating": true, "secretFields": [] },
|
||||
{ "id": "physical-command.reopen-retired-reconciliation", "mutating": true, "secretFields": [] }
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user