wip(k1): checkpoint connection recovery rewrite

Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 deletions
+15 -1
View File
@@ -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
+10 -4
View File
@@ -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 0102 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/WiFi 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":
"WiFi на этом 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: "WiFi 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),
});
}
+239 -1
View File
@@ -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;
}
+587 -34
View File
@@ -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
+406 -172
View File
@@ -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)
}
+9 -2
View File
@@ -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": [
{