feat(k1): stabilize LAB bridge and isolate onboard device integration
This commit is contained in:
@@ -14,6 +14,23 @@ This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
|
||||
runtime still owns one active model/session at a time. Concurrent model/session
|
||||
routing remains a later supervisor milestone.
|
||||
|
||||
The onboard sensor views and Bridge enrollment controller are also plugin-owned
|
||||
under `frontend/src/sensors`. The shared sensor workspace consumes optional
|
||||
`SensorUiContribution` renderers through `@mission-core/sensor-sdk`; the main
|
||||
frontend registers these on `DeviceUiPlugin.sensorUi`, and the Node frontend
|
||||
selects its installed contribution explicitly. An absent or ambiguous renderer
|
||||
does not fall through into another device's detail controls. The current single
|
||||
enrollment provider is explicit; a multi-provider enrollment picker is not yet
|
||||
implemented.
|
||||
|
||||
`connection_attempt.py` owns the pure join of network journal, owned control
|
||||
bootstrap, recovery verification and current control authority. LAB keeps the
|
||||
full public attempt; Node exports a compact allowlisted projection without
|
||||
timeline payloads or diagnostic bundles. The Node observer submits once and
|
||||
follows the same operation across response loss and bootstrap completion.
|
||||
These are read-model and presentation boundaries, not a new command scheduler
|
||||
or a claim of independently installable backend/process isolation.
|
||||
|
||||
The plugin owns:
|
||||
|
||||
- the exact firmware/topology compatibility profiles under
|
||||
|
||||
@@ -275,6 +275,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionAttemptFailed = !sourceRuntimeBusy && !preparedAcquisition
|
||||
&& connectionTopology?.status !== "active"
|
||||
&& state?.connection_attempt?.connection_mode === effectiveDesiredConnectionMode
|
||||
&& ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"]
|
||||
.includes(state.connection_attempt.status);
|
||||
const connectionControlBootstrapSettling =
|
||||
isConnectionControlBootstrapSettling(state);
|
||||
const connectionPhaseLabel = livePreparationPending
|
||||
@@ -293,6 +298,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? savedBridgeNetworkSetupRequired
|
||||
? "Нужна настройка сети"
|
||||
: "Требуется действие"
|
||||
: connectionAttemptFailed
|
||||
? "Подключение не завершено"
|
||||
: projectedPhase === "error"
|
||||
? connectionPhaseFallbackLabel(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
@@ -316,6 +323,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? "accent"
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: connectionAttemptFailed
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
? phaseTone(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
@@ -336,6 +345,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: physicalRecoveryRequired
|
||||
? physicalRecoveryDetail
|
||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||
: connectionAttemptFailed
|
||||
? "Проверьте состояние K1 или начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||
? "Начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {XgridsConnectionAttemptSummary} from './connectionAttempt';
|
||||
import type { ViewerSettings } from "@mission-core/plugin-sdk";
|
||||
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
|
||||
|
||||
@@ -826,28 +827,7 @@ export const XGRIDS_CONNECTION_ATTEMPT_PHASES = [
|
||||
export type XgridsConnectionAttemptPhase =
|
||||
typeof XGRIDS_CONNECTION_ATTEMPT_PHASES[number];
|
||||
|
||||
export interface XgridsConnectionAttempt {
|
||||
schema_version: "missioncore.xgrids-k1-connection-attempt/v1";
|
||||
attempt_id: string;
|
||||
connection_mode: XgridsConnectionMode;
|
||||
status: OperationStatus;
|
||||
stage: string;
|
||||
public_error_code: string | null;
|
||||
side_effect_status: string;
|
||||
phase: XgridsConnectionAttemptPhase;
|
||||
control_state: "ready" | "control_not_ready" | "unknown";
|
||||
safe_next_action:
|
||||
| "wait-for-current-attempt"
|
||||
| "continue-with-control-verification"
|
||||
| "verify-control-read-only"
|
||||
| "start-acquisition"
|
||||
| "stop-local-receiver"
|
||||
| "retire-unavailable-physical-target"
|
||||
| "scan-select-connect"
|
||||
| "manual-recovery-required";
|
||||
automatic_retry: false;
|
||||
accepted_at: string | null;
|
||||
completed_at: string | null;
|
||||
export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary {
|
||||
timeline: XgridsOperationEvent[];
|
||||
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { XgridsConnectionAttempt } from "../api";
|
||||
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||
|
||||
const connectionAttemptStageLabels: Record<string, string> = {
|
||||
accepted: "Запрос принят",
|
||||
"scan-selection-admitted": "Результат выбран",
|
||||
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
||||
"device-ap-activation": "Подготовка локальной сети",
|
||||
"ble-provisioning-write": "Передаются настройки сети",
|
||||
"ble-provisioning-write": "Подключение K1 к Wi‑Fi",
|
||||
"ble-write-dispatched": "Настройки переданы",
|
||||
"status-observing": "Ожидание ответа",
|
||||
"device-topology-applied": "Целевая сеть подтверждена",
|
||||
@@ -62,6 +63,8 @@ export function attemptNextActionLabel(
|
||||
return "Проверить управление без изменения сети";
|
||||
case "start-acquisition":
|
||||
return "Готово к запуску приёма";
|
||||
case "stop-acquisition":
|
||||
return "Остановить текущий приём";
|
||||
case "stop-local-receiver":
|
||||
return "Завершить только локальный приём";
|
||||
case "retire-unavailable-physical-target":
|
||||
@@ -74,6 +77,9 @@ export function attemptNextActionLabel(
|
||||
}
|
||||
|
||||
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
||||
...STATION_WIFI_FAILURE_MESSAGES,
|
||||
BleakGATTProtocolError:
|
||||
"K1 ответил ошибкой Bluetooth. Подключение к Wi‑Fi не подтверждено; проверьте состояние устройства или начните новое подключение.",
|
||||
"network-provision-discovery-generation-conflict":
|
||||
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
||||
"connection-mode-draft-revision-conflict":
|
||||
@@ -121,6 +127,7 @@ export function K1OperatorError({
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const structured = hostFailureDiagnosticPresentation(diagnostic);
|
||||
const stationFailure = STATION_WIFI_FAILURE_MESSAGES[attempt?.public_error_code ?? ""];
|
||||
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
|
||||
const copyDiagnosticBundle = async () => {
|
||||
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
|
||||
@@ -137,7 +144,7 @@ export function K1OperatorError({
|
||||
>
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div className="error-banner__copy">
|
||||
<strong>{title}</strong>
|
||||
<strong>{stationFailure ? "Не удалось подключить K1 к Wi‑Fi" : title}</strong>
|
||||
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
|
||||
{recoveryActions ? (
|
||||
<div className="error-banner__recovery-actions">
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
} from "../api";
|
||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||
import {
|
||||
BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
@@ -66,10 +67,12 @@ import {
|
||||
type ReadOnlyConnectionObservationTarget,
|
||||
} from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import { K1OperatorError } from "./K1OperatorError";
|
||||
import {
|
||||
connectionActionAuthoritySnapshot,
|
||||
networkProvisionFailureMessage,
|
||||
type BleDiscoverySubmitResult,
|
||||
type ConnectionActionAuthoritySnapshot,
|
||||
} from "../useXgridsK1Runtime";
|
||||
@@ -1866,7 +1869,7 @@ export function K1ProvisioningPipeline({
|
||||
const connectionRecoveryRequired = connectionRecoveryIsRequired(
|
||||
connectionRecoveryKey,
|
||||
escapedConnectionRecoveryKey,
|
||||
) && !(searchRequested && !searchDisplayActive);
|
||||
) && (connectionAttemptFailed || !(searchRequested && !searchDisplayActive));
|
||||
const presentedConnectionRecoveryRequired = !livePreparationPending && (
|
||||
connectionRecoveryRequired || connectionRecoveryVerificationPending
|
||||
);
|
||||
@@ -2089,7 +2092,7 @@ export function K1ProvisioningPipeline({
|
||||
const startedAt = Date.now();
|
||||
const updateCountdown = () => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
||||
setScanSecondsRemaining(Math.max(0, 6 - elapsedSeconds));
|
||||
setScanSecondsRemaining(Math.max(0, BLE_DISCOVERY_TIMEOUT_SECONDS - elapsedSeconds));
|
||||
};
|
||||
updateCountdown();
|
||||
const timer = window.setInterval(updateCountdown, 250);
|
||||
@@ -2350,7 +2353,7 @@ export function K1ProvisioningPipeline({
|
||||
let scanResult: BleDiscoverySubmitResult | null = null;
|
||||
try {
|
||||
if (!runtimeActionIsCurrent(actionFence)) return;
|
||||
scanResult = await scanWithResult({ durationSeconds: 6 });
|
||||
scanResult = await scanWithResult({ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS });
|
||||
if (
|
||||
!localScenarioActionEpochIsCurrent(
|
||||
actionEpoch,
|
||||
@@ -2573,12 +2576,12 @@ export function K1ProvisioningPipeline({
|
||||
}
|
||||
|
||||
const freshStartAllowed = result.intentDisposition === "release";
|
||||
const failureMessage = freshStartAllowed
|
||||
const failureMessage = networkProvisionFailureMessage(observedOperation) ?? (freshStartAllowed
|
||||
&& provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
|
||||
? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
|
||||
: freshStartAllowed
|
||||
? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
|
||||
: "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение.";
|
||||
: "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.");
|
||||
setConnectionAttemptPresentation((current) => (
|
||||
current?.idempotencyKey === idempotencyKey
|
||||
? {
|
||||
@@ -3203,7 +3206,9 @@ export function K1ProvisioningPipeline({
|
||||
disabled={isBusy || modeResetInFlight}
|
||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||
>
|
||||
{savedBridgeNetworkSetupRequired
|
||||
{STATION_WIFI_FAILURE_MESSAGES[connectionRecoveryAttempt?.public_error_code ?? ""]
|
||||
? "Указать сеть Wi‑Fi заново"
|
||||
: savedBridgeNetworkSetupRequired
|
||||
? "Подключить K1 к общей сети"
|
||||
: "Подключить новый K1"}
|
||||
</Button>
|
||||
@@ -3256,7 +3261,7 @@ export function K1ProvisioningPipeline({
|
||||
title="Подключение"
|
||||
status={
|
||||
searchActive
|
||||
? `Поиск · ${scanSecondsRemaining ?? 6} с`
|
||||
? `Поиск · до ${scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с`
|
||||
: connectionAttemptSettling
|
||||
? "Устройство выбрано"
|
||||
: presentedPhysicalRecoveryRequired
|
||||
@@ -3316,7 +3321,7 @@ export function K1ProvisioningPipeline({
|
||||
>
|
||||
<ActivityIndicator />
|
||||
<strong>
|
||||
Поиск Bluetooth · {scanSecondsRemaining ?? 6} с
|
||||
Поиск Bluetooth · до {scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с
|
||||
</strong>
|
||||
</div>
|
||||
) : networkRecoveryRequired
|
||||
|
||||
@@ -4,6 +4,9 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
|
||||
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
||||
export type GnssMode = "none" | "rtk" | "ppk";
|
||||
|
||||
// Upper bound for one continuous discovery session; it can finish earlier when K1 is visible.
|
||||
export const BLE_DISCOVERY_TIMEOUT_SECONDS = 20;
|
||||
|
||||
export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
||||
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
||||
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Public read model. Journal and current backend policy remain authoritative. */
|
||||
export interface XgridsConnectionAttemptSummary {
|
||||
schema_version:'missioncore.xgrids-k1-connection-attempt/v1';
|
||||
attempt_id:string;
|
||||
connection_mode:'bridge'|'quick-connect'|'direct-connect';
|
||||
status:'accepted'|'running'|'operator_action_required'|'succeeded'|'failed'|'cancelled'|'timed_out'|'interrupted';
|
||||
phase:'network_applied'|'network_not_applied'|'network_outcome_unknown';
|
||||
control_state:'ready'|'control_not_ready'|'unknown';
|
||||
stage:string;
|
||||
public_error_code:string|null;
|
||||
side_effect_status:string;
|
||||
safe_next_action:'wait-for-current-attempt'|'continue-with-control-verification'|'verify-control-read-only'|'start-acquisition'|'stop-acquisition'|'stop-local-receiver'|'retire-unavailable-physical-target'|'scan-select-connect'|'manual-recovery-required';
|
||||
automatic_retry:false;
|
||||
accepted_at:string|null;
|
||||
completed_at:string|null;
|
||||
recovery_operation_id?:string|null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Reviewed K1 station replies; Quick Connect host errors have separate copy. */
|
||||
export const STATION_WIFI_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
|
||||
"k1-wifi-network-not-found":
|
||||
"K1 не смог подключиться к сети Wi‑Fi: указанная сеть не найдена. Проверьте название сети и пароль. Убедитесь, что сеть доступна рядом с K1, и укажите её заново.",
|
||||
"k1-wifi-credentials-required":
|
||||
"K1 не смог подключиться к сети Wi‑Fi: устройство запросило учётные данные сети. Проверьте название сети и пароль, затем укажите сеть заново.",
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ApiError, type ConnectRequest, type XgridsK1State, type XgridsOperation } from "./api";
|
||||
import { operationByIdempotencyKey } from "./lifecycle";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation";
|
||||
|
||||
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
||||
const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000;
|
||||
const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000;
|
||||
const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000;
|
||||
|
||||
function networkProvisionSettlementDeadline(
|
||||
operation: XgridsOperation,
|
||||
startedAtMs: number,
|
||||
): number {
|
||||
const operationDeadlineMs = operation.deadline_at
|
||||
? Date.parse(operation.deadline_at)
|
||||
: Number.NaN;
|
||||
const requestedDeadlineMs = Number.isFinite(operationDeadlineMs)
|
||||
&& operationDeadlineMs > startedAtMs
|
||||
? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS
|
||||
: startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS;
|
||||
return Math.min(
|
||||
requestedDeadlineMs,
|
||||
startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one already-admitted Apply after a browser response is interrupted by
|
||||
* the host Wi-Fi handoff. This loop only reads the local journal; it never
|
||||
* retries the HTTP mutation, BLE write, CoreWLAN association or control open.
|
||||
*/
|
||||
export async function awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
initialState: XgridsK1State,
|
||||
idempotencyKey: string,
|
||||
readState: () => Promise<XgridsK1State>,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
assertOperatorIntentCurrent: () => void,
|
||||
options: {
|
||||
now?: () => number;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<XgridsK1State> {
|
||||
const now = options.now ?? Date.now;
|
||||
const wait = options.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
|
||||
globalThis.setTimeout(resolve, delayMs);
|
||||
}));
|
||||
let state = initialState;
|
||||
let operation = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
idempotencyKey,
|
||||
);
|
||||
if (!operation || !["accepted", "running"].includes(operation.status)) {
|
||||
return state;
|
||||
}
|
||||
const deadlineMs = networkProvisionSettlementDeadline(operation, now());
|
||||
|
||||
while (["accepted", "running"].includes(operation.status)) {
|
||||
assertOperatorIntentCurrent();
|
||||
const remainingMs = deadlineMs - now();
|
||||
if (remainingMs <= 0) return state;
|
||||
await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs));
|
||||
assertOperatorIntentCurrent();
|
||||
try {
|
||||
const nextState = await readState();
|
||||
assertOperatorIntentCurrent();
|
||||
acceptState(nextState);
|
||||
state = nextState;
|
||||
} catch (readError) {
|
||||
// A host Wi-Fi transition can briefly abort even a localhost fetch.
|
||||
// Preserve the admitted operation and perform only the next bounded
|
||||
// journal read; the original Apply is never reissued.
|
||||
if (!(readError instanceof ApiError) || !readError.transportUnavailable) {
|
||||
throw readError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
operation = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
idempotencyKey,
|
||||
);
|
||||
if (!operation) return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export interface ProvisioningObservation {
|
||||
state: XgridsK1State;
|
||||
operation: XgridsOperation | null;
|
||||
/** HTTP failure is subordinate to the journal; it never authorizes a retry. */
|
||||
transportError: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One explicit intent: submit at most once, then observe its exact journal row.
|
||||
* Bluetooth discovery, host WLAN changes and the vendor control dialogue stay
|
||||
* server-owned. No error path can resubmit or silently choose another device.
|
||||
*/
|
||||
export async function observeProvisioningRequest(
|
||||
request: ConnectRequest,
|
||||
context: {
|
||||
initialState: XgridsK1State | null;
|
||||
send: (request: ConnectRequest) => Promise<XgridsK1State>;
|
||||
readState: () => Promise<XgridsK1State>;
|
||||
acceptState: (state: XgridsK1State) => void;
|
||||
currentState: () => XgridsK1State | null;
|
||||
assertCurrent: () => void;
|
||||
settlementOptions?: Parameters<typeof awaitNetworkProvisionSettlementAfterLostResponse>[5];
|
||||
},
|
||||
): Promise<ProvisioningObservation> {
|
||||
let state = context.initialState;
|
||||
let transportError: unknown = null;
|
||||
const adopt = (incoming: XgridsK1State): XgridsK1State => {
|
||||
context.acceptState(incoming);
|
||||
context.assertCurrent();
|
||||
state = selectMonotonicXgridsState(context.currentState(), incoming);
|
||||
return state;
|
||||
};
|
||||
context.assertCurrent();
|
||||
const existing = operationByIdempotencyKey(state, "network.provision", request.idempotency_key);
|
||||
if (!existing) {
|
||||
let response: XgridsK1State;
|
||||
try {
|
||||
response = await context.send(request);
|
||||
} catch (error) {
|
||||
transportError = error;
|
||||
context.assertCurrent();
|
||||
try {
|
||||
response = await context.readState();
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
adopt(response);
|
||||
}
|
||||
if (!state) throw new ApiError("Состояние подключения недоступно. Обновите экран перед новым действием.");
|
||||
const settled = await awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
state,
|
||||
request.idempotency_key,
|
||||
async () => adopt(await context.readState()),
|
||||
adopt,
|
||||
context.assertCurrent,
|
||||
context.settlementOptions,
|
||||
);
|
||||
state = adopt(settled);
|
||||
return {
|
||||
state,
|
||||
operation: operationByIdempotencyKey(state, "network.provision", request.idempotency_key),
|
||||
transportError,
|
||||
};
|
||||
}
|
||||
|
||||
const CONNECT_FAILURE_NEXT_STEP_COPY =
|
||||
"Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.";
|
||||
|
||||
function networkFailureNextStepMessage(detail: string): string {
|
||||
return `${detail} ${CONNECT_FAILURE_NEXT_STEP_COPY}`;
|
||||
}
|
||||
|
||||
export function networkProvisionFailureMessage(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): string | null {
|
||||
if (!operation || operation.status !== "failed") return null;
|
||||
const code = operation.error?.code;
|
||||
if (typeof code !== "string") return null;
|
||||
if (STATION_WIFI_FAILURE_MESSAGES[code]) return STATION_WIFI_FAILURE_MESSAGES[code];
|
||||
|
||||
if (code === "BleakGATTProtocolError") {
|
||||
const attCode = operation.error?.ble_att_error_code;
|
||||
const attName = operation.error?.ble_att_error_name;
|
||||
const attDetail = typeof attCode === "number" && typeof attName === "string"
|
||||
? ` Код периферии: ATT ${attCode} ${attName}.`
|
||||
: "";
|
||||
if (operation.error?.device_write_attempted === false) {
|
||||
return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`;
|
||||
}
|
||||
if (typeof attCode === "number" && typeof attName === "string") {
|
||||
return networkFailureNextStepMessage(
|
||||
`Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`,
|
||||
);
|
||||
}
|
||||
return networkFailureNextStepMessage(
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.",
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "network-not-found") {
|
||||
const attemptCount = operation.error?.scan_attempt_count;
|
||||
const elapsedMs = operation.error?.scan_elapsed_ms;
|
||||
const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number"
|
||||
? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.`
|
||||
: "";
|
||||
return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`;
|
||||
}
|
||||
|
||||
if (
|
||||
code === "keychain-authorization-required"
|
||||
|| code === "keychain-authorization-denied"
|
||||
|| code === "keychain-authorization-cancelled"
|
||||
|| code === "keychain-access-failed"
|
||||
) {
|
||||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none";
|
||||
return failedBeforeDeviceWrite
|
||||
? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение."
|
||||
: networkFailureNextStepMessage(
|
||||
"После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.",
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"host-wifi-operation-timeout":
|
||||
networkFailureNextStepMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."),
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.",
|
||||
"profile-credential-source-mismatch":
|
||||
"Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.",
|
||||
"profile-unavailable":
|
||||
networkFailureNextStepMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."),
|
||||
"corewlan-error":
|
||||
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
|
||||
"wifi-interface-unavailable":
|
||||
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
|
||||
"unsupported-platform":
|
||||
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
|
||||
"credential-source-unavailable":
|
||||
"Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.",
|
||||
"network-provision-candidate-not-fresh":
|
||||
"Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.",
|
||||
"network-provision-candidate-changed":
|
||||
"Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.",
|
||||
"network-provision-candidate-name-unavailable":
|
||||
"K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.",
|
||||
"network-provision-target-not-distinguishable-from-baseline":
|
||||
networkFailureNextStepMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."),
|
||||
"network-provision-lifecycle-busy":
|
||||
"Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.",
|
||||
};
|
||||
return messages[code] ?? null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {xgridsK1SensorUi,K1EnrollmentWindow} from './sensors/plugin';
|
||||
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
||||
import { XgridsK1Connection } from "./XgridsK1Connection";
|
||||
import { K1SpatialControls } from "./components/K1SpatialControls";
|
||||
@@ -7,6 +8,7 @@ import "./styles.css";
|
||||
|
||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||
manifest: xgridsK1Manifest,
|
||||
sensorUi: {contributions: [xgridsK1SensorUi], Enrollment: K1EnrollmentWindow},
|
||||
RuntimeProvider: XgridsK1RuntimeProvider,
|
||||
SpatialControlsView: K1SpatialControls,
|
||||
connectionViews: Object.freeze({
|
||||
|
||||
@@ -90,9 +90,9 @@ const connectionPolicyNextActionCopy: Record<string, string> = {
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,type EnrollmentState,type EnrollmentTransport} from './enrollment';
|
||||
|
||||
export function DeviceEnrollmentWindow({transport,onClose,onChange}:{transport:EnrollmentTransport;onClose:()=>void;onChange:()=>void}){
|
||||
const [state,setState]=useState<EnrollmentState|null>(null);
|
||||
const [device,setDevice]=useState('');const [ssid,setSSID]=useState('');const [password,setPassword]=useState('');
|
||||
const [networks,setNetworks]=useState<NonNullable<EnrollmentState['networks']>>([]);
|
||||
const [network,setNetwork]=useState('manual');const [busy,setBusy]=useState('loading');
|
||||
const [error,setError]=useState('');const [scanned,setScanned]=useState(false);
|
||||
const lifetime=useRef<AbortController|null>(null);const running=useRef(false);
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();lifetime.current=controller;setState(null);setBusy('loading');
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
async function observe(){
|
||||
try{const value=await transport.state();if(!controller.signal.aborted)setState(current=>mergeEnrollmentState(current,value));}
|
||||
catch{if(!controller.signal.aborted)setState(current=>current?{...current,available:false,fresh:false}:null);}
|
||||
finally{if(!controller.signal.aborted){setBusy(current=>current==='loading'?'':current);timer=setTimeout(()=>void observe(),3000);}}
|
||||
}
|
||||
void observe();return()=>{controller.abort();if(timer)clearTimeout(timer);};
|
||||
},[transport]);
|
||||
const attempt=connectionAttempt(state);
|
||||
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
|
||||
const notice=state?enrollmentNotice(state):'';
|
||||
useEffect(()=>{setDevice('');setPassword('');},[state?.runtime_id,state?.discovery_generation,state?.mode_revision]);
|
||||
const ready=state?.available&&state.fresh!==false&&!!state.runtime_id;
|
||||
const selected=state?.candidates?.some(v=>v.id===device);
|
||||
async function run(action:'scan'|'networks'|'connect'|'verify'){
|
||||
if(!state||busy||running.current)return;running.current=true;const signal=lifetime.current?.signal;setBusy(action);setError('');
|
||||
const parameters=action==='connect'||action==='verify'?{device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,...(action==='connect'?{ssid,password}:{})}:{};
|
||||
if(action==='connect')setPassword('');
|
||||
try {
|
||||
const result=await enroll(transport,state,action,parameters,{signal,onState:value=>{if(!signal?.aborted)setState(current=>mergeEnrollmentState(current,value));}});
|
||||
if(signal?.aborted)return;onChange();
|
||||
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
|
||||
if(action==='scan'){setDevice('');setScanned(true);}
|
||||
if(action==='networks')setNetworks(result.networks??[]);
|
||||
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
|
||||
}catch(e){if(!signal?.aborted)setError(e instanceof Error?e.message:'Не удалось выполнить действие K1.');}
|
||||
finally{running.current=false;if(!signal?.aborted)setBusy('');}
|
||||
}
|
||||
const close=()=>{setPassword('');onClose();};
|
||||
return <Window open title="Подключение устройства к БК" onClose={close} footer={<WindowFooterActions>
|
||||
<Button onClick={close}>Закрыть</Button><Button variant="primary" disabled={!ready||!!busy||waiting||!selected||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} onClick={()=>void run('connect')}>Подключить</Button>
|
||||
</WindowFooterActions>}><div className="sensor-content" aria-busy={!!busy}>
|
||||
<SettingsCard title={state?.name||'Бортовой компьютер'} description="K1 подключится к выбранной сети Wi-Fi рядом с этим БК. Сеть компьютера оператора может отличаться.">
|
||||
<ResourceRow title="XGRIDS K1 · Bridge" status={<StatusBadge tone={ready?'success':'neutral'}>{ready?'БК доступен':busy==='loading'?'Получаем сведения':'Подключение недоступно'}</StatusBadge>}/>
|
||||
</SettingsCard>
|
||||
{busy==='loading'?<ActivityIndicator label="Получаем состояние БК"/>:!ready?<SettingsCard title="Служба подключения устройств на БК недоступна" description="Проверьте связь с бортовым компьютером и работу приложения на нём."/>:<>
|
||||
<ResourceRow title="Устройства рядом с БК" description="Включите K1 для поиска по Bluetooth." actions={<Button disabled={!!busy||waiting||!enrollmentAllowed(state,'scan')} onClick={()=>void run('scan')}>Найти K1</Button>}/>
|
||||
{scanned&&!state?.candidates?.length&&<SettingsCard title="K1 не найден" description="Проверьте питание K1 и Bluetooth на бортовом компьютере, затем повторите поиск."/>}
|
||||
{!!state?.candidates?.length&&<Select label="Устройство K1" value={device} options={[{value:'',label:'Выберите K1',disabled:true},...state.candidates.map(v=>({value:v.id,label:v.name}))]} onChange={setDevice} disabled={!!busy||waiting}/>}
|
||||
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите доступную сеть или введите её название. БК должен иметь доступ к этой сети." actions={<Button disabled={!!busy||waiting} onClick={()=>void run('networks')}>Найти сети</Button>}/>
|
||||
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network} options={[{value:'manual',label:'Ввести название сети'},...networks.map((v,i)=>({value:String(i),label:v.ssid,description:`Сигнал ${v.signal}% · ${v.security||'Без защиты'}`}))]} onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={!!busy||waiting}/>}
|
||||
<TextField label="Название сети Wi-Fi" value={ssid} onChange={e=>{setSSID(e.target.value);setNetwork('manual');}} disabled={!!busy||waiting} autoComplete="off"/>
|
||||
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={!!busy||waiting} autoComplete="new-password"/>
|
||||
<Button disabled={!!busy||waiting||!selected||!enrollmentAllowed(state,'verify')} onClick={()=>void run('verify')}>Проверить текущее подключение</Button>
|
||||
</>}
|
||||
{!!busy&&busy!=='loading'&&<ActivityIndicator label={busy==='scan'?'Ищем K1 на БК':busy==='networks'?'Ищем сети рядом с БК':'Проверяем подключение K1'}/>}
|
||||
{notice&&<SettingsCard title={notice}/>}<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div></Window>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import {K1LiveSettings} from './K1LiveSettings';
|
||||
|
||||
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
||||
const [busy,setBusy]=useState(false),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
|
||||
const streaming=device.snapshot.acquisition==='streaming';
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||
async function act(action:'start'|'stop'|'verify'){
|
||||
if(busy)return;setBusy(true);failure(null);
|
||||
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
||||
catch(e){failure(e);}finally{setBusy(false);}
|
||||
}
|
||||
return <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={streaming&&enabled?'success':'neutral'}>{streaming?'Живой просмотр':'K1 · Bridge'}</StatusBadge><IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></div>
|
||||
<SettingsCard title={device.name} description="Камера и лидар работают на бортовом компьютере. Исходные данные записи сохраняются на БК.">
|
||||
<div className="sensor-actions"><Button disabled={!enabled||busy||streaming||!device.control?.can_start} onClick={()=>void act('start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!streaming&&!device.control?.can_stop)} onClick={()=>void act('stop')}>Остановить</Button><Button disabled={!enabled||busy} onClick={()=>{void refresh();setGeneration(v=>v+1);}}>Обновить просмотр</Button><IconButton label="Настройки живого просмотра" onClick={()=>setSettings(v=>!v)}><Icon name="settings"/></IconButton></div>
|
||||
</SettingsCard>
|
||||
{settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost}/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
|
||||
export interface LiveSettings {
|
||||
point_size:number; accumulation_seconds:number;
|
||||
color_mode:'intensity'|'height'|'distance'|'rgb'|'class';
|
||||
palette:'turbo'|'viridis'|'plasma'|'grayscale'|'custom'; custom_color:string;
|
||||
show_points:boolean;show_trajectory:boolean;show_grid:boolean;
|
||||
show_detections_2d:boolean;show_segmentation:boolean;show_cuboids_3d:boolean;
|
||||
}
|
||||
const defaults:LiveSettings={point_size:2.5,accumulation_seconds:12,color_mode:'intensity',palette:'turbo',custom_color:'#f7f8f4',show_points:true,show_trajectory:true,show_grid:true,show_detections_2d:false,show_segmentation:false,show_cuboids_3d:false};
|
||||
|
||||
export function K1LiveSettings({device,transport,enabled,refresh,failure}:{device:Sensor;transport:SensorTransport;enabled:boolean;refresh:()=>Promise<void>;failure:(error:unknown)=>void}){
|
||||
const [settings,setSettings]=useState<LiveSettings>({...defaults,...device.live_settings});
|
||||
const [busy,setBusy]=useState(false);
|
||||
const valid=Number.isFinite(settings.point_size)&&settings.point_size>=0.5&&settings.point_size<=12&&Number.isFinite(settings.accumulation_seconds)&&settings.accumulation_seconds>=0&&settings.accumulation_seconds<=120;
|
||||
async function apply(){setBusy(true);failure(null);try{await perform(transport,device,'option',{profile:'live-acquisition',settings});await refresh();}catch(error){failure(error);}finally{setBusy(false);}}
|
||||
return <SettingsCard title="Живой просмотр" description="Настройки текущего потока с БК. Воспроизведение записей и результаты LAB настраиваются отдельно.">
|
||||
<div className="sensor-fields">
|
||||
<TextField label="Размер точек" type="number" min={0.5} max={12} step={0.5} value={String(settings.point_size)} disabled={busy} onChange={event=>setSettings(v=>({...v,point_size:Number(event.target.value)}))}/>
|
||||
<TextField label="Накопление, с" type="number" min={0} max={120} step={1} value={String(settings.accumulation_seconds)} disabled={busy} onChange={event=>setSettings(v=>({...v,accumulation_seconds:Number(event.target.value)}))}/>
|
||||
<Select label="Цвет точек" value={settings.color_mode} disabled={busy} options={[{value:'intensity',label:'Интенсивность'},{value:'height',label:'Высота'},{value:'distance',label:'Расстояние'},{value:'rgb',label:'Цвет камеры'},{value:'class',label:'Класс'}]} onChange={value=>setSettings(v=>({...v,color_mode:value as LiveSettings['color_mode']}))}/>
|
||||
<Select label="Палитра" value={settings.palette} disabled={busy} options={[{value:'turbo',label:'Turbo'},{value:'viridis',label:'Viridis'},{value:'plasma',label:'Plasma'},{value:'grayscale',label:'Оттенки серого'}]} onChange={value=>setSettings(v=>({...v,palette:value as LiveSettings['palette']}))}/>
|
||||
</div>
|
||||
<div className="sensor-actions"><Switch label="Точки" checked={settings.show_points} disabled={busy} onChange={value=>setSettings(v=>({...v,show_points:value}))}/><Switch label="Траектория" checked={settings.show_trajectory} disabled={busy} onChange={value=>setSettings(v=>({...v,show_trajectory:value}))}/><Switch label="Сетка" checked={settings.show_grid} disabled={busy} onChange={value=>setSettings(v=>({...v,show_grid:value}))}/><Button disabled={!enabled||busy||!valid} onClick={()=>void apply()}>Применить</Button></div>
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
|
||||
function privateCandidate(sdp:string):string{
|
||||
return sdp.split('\r\n').filter(line=>{
|
||||
if(!line.startsWith('a=candidate:'))return true;
|
||||
const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number);
|
||||
return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&&
|
||||
(parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127))));
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory}){
|
||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
|
||||
useEffect(()=>{
|
||||
let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined;
|
||||
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined;
|
||||
let closeChannel:(()=>void)|undefined;
|
||||
const host=createRerunHost(spatial.current!);
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true});
|
||||
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
|
||||
const fail=(message:string)=>{failed=true;if(active){setError(message);setReady(false);}clearInterval(follow);clearInterval(keepalive);pc.close();};
|
||||
const cameraFail=(message:string)=>{if(active)setCameraError(message);camera.close();};
|
||||
rrd.onclose=()=>{if(active&&!failed)fail('Поток лидара завершён. Обновите состояние устройства.');};
|
||||
camera.onclose=()=>{if(active)setCameraError('Поток камеры недоступен. Обновите просмотр.');};
|
||||
const run=async()=>{
|
||||
const {viewer,mount}=await host.ready;if(!active)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return;
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);closeChannel=()=>channel.close();
|
||||
rrd.onmessage=event=>{if(!active||!(event.data instanceof ArrayBuffer))return;try{channel.send_rrd(new Uint8Array(event.data));}catch{fail('Поток лидара прерван. Обновите просмотр.');}};
|
||||
follow=setInterval(()=>{if(!active)return;try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){setReady(true);viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);}}catch{/* Viewer may still be opening its recording. */}},250);
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{const timeout=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),8000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(timeout);resolve();}};});
|
||||
if(!active)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer';camera_mime?:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
|
||||
peerID=answer.peer_id;
|
||||
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;}
|
||||
if(answer.camera_mime&&typeof MediaSource!=='undefined'&&MediaSource.isTypeSupported(answer.camera_mime)){
|
||||
const media=new MediaSource();mediaURL=URL.createObjectURL(media);video.current!.src=mediaURL;
|
||||
const pending:ArrayBuffer[]=[];let pendingBytes=0,buffer:SourceBuffer|undefined;
|
||||
const append=()=>{if(!active||!buffer||buffer.updating||media.readyState!=='open')return;try{
|
||||
const current=video.current?.currentTime??0;
|
||||
if(buffer.buffered.length&¤t-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),current-5);return;}
|
||||
const bytes=pending.shift();if(bytes){pendingBytes-=bytes.byteLength;buffer.appendBuffer(bytes);}
|
||||
}catch{cameraFail('Поток камеры прерван. Обновите просмотр.');}};
|
||||
media.addEventListener('sourceopen',()=>{if(!active)return;buffer=media.addSourceBuffer(answer.camera_mime!);buffer.addEventListener('updateend',()=>{const element=video.current;if(element&&buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-element.currentTime>1)element.currentTime=Math.max(0,end-0.2);void element.play().catch(()=>{});}append();});append();},{once:true});
|
||||
camera.onmessage=event=>{if(!(event.data instanceof ArrayBuffer)||!active)return;pendingBytes+=event.data.byteLength;if(pendingBytes>8*1024*1024){cameraFail('Просмотр камеры не успевает за потоком. Обновите просмотр.');return;}pending.push(event.data);append();};
|
||||
}
|
||||
else cameraFail('Камера пока недоступна в этом просмотре.');
|
||||
pc.onconnectionstatechange=()=>{if(active&&['failed','disconnected','closed'].includes(pc.connectionState))fail('Связь просмотра прервана. Обновите просмотр.');};
|
||||
await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});
|
||||
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
|
||||
};
|
||||
void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.'));
|
||||
return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);};
|
||||
},[device.snapshot.context.session_id,transport,createRerunHost]);
|
||||
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {EnrollmentState,EnrollmentCommand,EnrollmentOperation,EnrollmentTransport} from '@mission-core/sensor-sdk';
|
||||
import type {XgridsConnectionAttemptSummary} from '../connectionAttempt';
|
||||
import {STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation';
|
||||
export type {EnrollmentState,EnrollmentTransport} from '@mission-core/sensor-sdk';
|
||||
|
||||
export function connectionAttempt(state:EnrollmentState|null):XgridsConnectionAttemptSummary|null {
|
||||
const value=state?.connection_attempt;
|
||||
if(!value||typeof value!=='object')return null;
|
||||
const candidate=value as Partial<XgridsConnectionAttemptSummary>;
|
||||
return candidate.schema_version==='missioncore.xgrids-k1-connection-attempt/v1'&&typeof candidate.attempt_id==='string'
|
||||
?candidate as XgridsConnectionAttemptSummary:null;
|
||||
}
|
||||
|
||||
/** Older responses cannot restore readiness or overwrite a newer runtime. */
|
||||
export function mergeEnrollmentState(current:EnrollmentState|null,incoming:EnrollmentState):EnrollmentState {
|
||||
if(!current)return incoming;
|
||||
if(incoming.node_id&¤t.node_id&&incoming.node_id!==current.node_id)return current;
|
||||
if(!incoming.available||incoming.fresh===false)return {...current,available:false,fresh:false};
|
||||
if(current.runtime_id&&incoming.runtime_id!==current.runtime_id){
|
||||
if(!incoming.runtime_started_at)return current;
|
||||
if(current.runtime_started_at&&incoming.runtime_started_at<=current.runtime_started_at)return current;
|
||||
}else if((incoming.snapshot_revision??-1)<(current.snapshot_revision??-1))return current;
|
||||
return {...incoming,node_id:current.node_id,name:current.name??incoming.name,fresh:incoming.fresh??current.fresh};
|
||||
}
|
||||
|
||||
export function enrollmentCommand(state:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={}):EnrollmentCommand {
|
||||
if(!state.available||state.fresh===false||!state.runtime_id)throw new Error('БК или служба K1 недоступны. Обновите сведения.');
|
||||
return {operation_id:'op_'+crypto.randomUUID().replaceAll('-',''),node_id:state.node_id,runtime_id:state.runtime_id,action,
|
||||
deadline_at:new Date(Date.now()+170000).toISOString(),parameters};
|
||||
}
|
||||
|
||||
export function enrollmentAllowed(state:EnrollmentState|null,action:EnrollmentCommand['action']):boolean {
|
||||
if(!state?.available||state.fresh===false)return false;
|
||||
if(!state.allowed_actions)return true; // Older driver, server admission still applies.
|
||||
if(action==='networks')return true;
|
||||
const permissions=action==='scan'?['scan-ble']:action==='connect'?['provision-fresh-device']:
|
||||
['verify-control-read-only','observe-current-device-network','observe-configured-device-network','observe-fresh-device-network'];
|
||||
return permissions.some(value=>state.allowed_actions?.includes(value));
|
||||
}
|
||||
|
||||
export interface EnrollmentObserver {
|
||||
signal?:AbortSignal;
|
||||
onState?:(state:EnrollmentState)=>void;
|
||||
now?:()=>number;
|
||||
pause?:()=>Promise<void>;
|
||||
}
|
||||
const unknownResult='Результат подключения пока не подтверждён. Обновите состояние K1 перед новой попыткой.';
|
||||
|
||||
/** Submit once. HTTP delivery and the device's network/control result are separate. */
|
||||
export async function enroll(transport:EnrollmentTransport,initial:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={},observer:EnrollmentObserver={}):Promise<EnrollmentState>{
|
||||
const command=enrollmentCommand(initial,action,parameters);
|
||||
const now=observer.now??Date.now;
|
||||
// The physical journal has a 240 s budget. Beyond delivery we only observe.
|
||||
const observationDeadline=Date.parse(command.deadline_at)+80000;
|
||||
const assertActive=()=>{if(observer.signal?.aborted)throw new Error('Наблюдение закрыто.');};
|
||||
const pause=observer.pause??(()=>new Promise<void>(resolve=>setTimeout(resolve,1000)));
|
||||
let state=initial;
|
||||
const adopt=(value:EnrollmentState)=>{
|
||||
assertActive();
|
||||
const normalized={...value,node_id:value.node_id||initial.node_id};
|
||||
state=mergeEnrollmentState(state,normalized);observer.onState?.(state);
|
||||
if(state.runtime_id!==command.runtime_id)throw new Error('Сеанс K1 изменился.');
|
||||
return state;
|
||||
};
|
||||
let operation:EnrollmentOperation|null=null;
|
||||
assertActive();
|
||||
try {operation=await transport.submit(command);}catch{/* Resolve only this ID after a lost response. */}
|
||||
finally{delete command.parameters.password;}
|
||||
while(true){
|
||||
assertActive();
|
||||
if(operation?.operation_id&&operation.operation_id!==command.operation_id)throw new Error('Получен ответ на другое действие. Обновите состояние K1.');
|
||||
if(operation?.result)adopt(operation.result);
|
||||
if(operation&&operation.state!=='running'&&operation.state!=='queued')break;
|
||||
if(now()>=observationDeadline)throw new Error(unknownResult);
|
||||
await pause();assertActive();
|
||||
try{operation=await transport.operation(command.operation_id);}catch{operation=null;}
|
||||
}
|
||||
if(action==='scan'||action==='networks'){
|
||||
if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||unknownResult);
|
||||
return state;
|
||||
}
|
||||
// A network ACK can precede its owned DeviceInfo bootstrap. Keep observing
|
||||
// the same attempt; no second POST, verify command or target selection occurs.
|
||||
while(true){
|
||||
assertActive();
|
||||
const attempt=connectionAttempt(state);
|
||||
const exactAttempt=attempt&&(attempt.attempt_id===command.operation_id||attempt.recovery_operation_id===command.operation_id);
|
||||
const exactCommand=state.command_result?.operation_id===command.operation_id;
|
||||
const terminalAttempt=exactAttempt&&attempt.status!=='accepted'&&attempt.status!=='running';
|
||||
if(terminalAttempt)return state;
|
||||
if(action==='verify'&&operation.state==='complete'&&operation.result&&state.selected_device_id===parameters.device_id&&state.connected)return state;
|
||||
if(exactCommand&&['failed','rejected'].includes(state.command_result?.status??''))return state;
|
||||
// Legacy drivers do not publish attempts. Do not infer success from a stale
|
||||
// connected lamp or silently provision again after a transport failure.
|
||||
if(operation.state==='complete'&&operation.result&&!attempt)return state;
|
||||
if(now()>=observationDeadline)throw new Error(unknownResult);
|
||||
await pause();assertActive();
|
||||
try{adopt(await transport.state());}catch(error){
|
||||
assertActive();
|
||||
if(state.runtime_id!==command.runtime_id)throw error;
|
||||
// Read outages are recoverable. Do not reinterpret them as a Wi-Fi refusal.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function enrollmentNotice(state:EnrollmentState):string {
|
||||
const attempt=connectionAttempt(state);
|
||||
if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.';
|
||||
if(attempt?.status==='accepted'||attempt?.status==='running'){
|
||||
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
|
||||
}
|
||||
const code=attempt?.public_error_code??state.command_result?.error_code;
|
||||
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
|
||||
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
|
||||
if(state.connected)return 'K1 подключён к БК.';
|
||||
if(attempt?.phase==='network_applied')return 'Настройки Wi-Fi применены. Связь с K1 пока не подтверждена; проверьте текущее подключение.';
|
||||
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
|
||||
if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function bridgeFormValid(ssid:string,password:string):boolean{
|
||||
const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
|
||||
import {K1Detail} from './K1Detail';
|
||||
export {DeviceEnrollmentWindow as K1EnrollmentWindow} from './DeviceEnrollmentWindow';
|
||||
|
||||
export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import {perform as performSensor, type Sensor, type SensorTransport} from '@mission-core/sensor-sdk';
|
||||
export type {Sensor, SensorTransport} from '@mission-core/sensor-sdk';
|
||||
|
||||
/** K1's established control-operation budget belongs to the integration. */
|
||||
export function perform<T>(transport:SensorTransport, device:Sensor, action:string, parameters:Record<string,unknown>={}):Promise<T> {
|
||||
return performSensor(transport,device,action,parameters,['start','verify','stop'].includes(action)?175000:60000);
|
||||
}
|
||||
@@ -39,8 +39,6 @@ import {
|
||||
newMutationContext,
|
||||
newOperationId,
|
||||
operationAllowsFreshProvisioningIntent,
|
||||
operationByIdempotencyKey,
|
||||
operationNeedsReconciliation,
|
||||
physicalStopIntentCheckpoint,
|
||||
recommendedConnectionRecoveryObservationTarget,
|
||||
readOnlyVerificationClearedReconciliation,
|
||||
@@ -65,7 +63,9 @@ import {
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
|
||||
import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
|
||||
import { DEFAULT_CONNECTION_MODE } from "./configuration";
|
||||
import { BLE_DISCOVERY_TIMEOUT_SECONDS, DEFAULT_CONNECTION_MODE } from "./configuration";
|
||||
import { observeProvisioningRequest, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||
export { awaitNetworkProvisionSettlementAfterLostResponse, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||
|
||||
export type PendingAction =
|
||||
| "scan"
|
||||
@@ -265,87 +265,6 @@ export function connectionActionAuthoritySnapshot(
|
||||
}
|
||||
|
||||
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
||||
const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000;
|
||||
const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000;
|
||||
const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000;
|
||||
|
||||
function networkProvisionSettlementDeadline(
|
||||
operation: XgridsOperation,
|
||||
startedAtMs: number,
|
||||
): number {
|
||||
const operationDeadlineMs = operation.deadline_at
|
||||
? Date.parse(operation.deadline_at)
|
||||
: Number.NaN;
|
||||
const requestedDeadlineMs = Number.isFinite(operationDeadlineMs)
|
||||
&& operationDeadlineMs > startedAtMs
|
||||
? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS
|
||||
: startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS;
|
||||
return Math.min(
|
||||
requestedDeadlineMs,
|
||||
startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one already-admitted Apply after a browser response is interrupted by
|
||||
* the host Wi-Fi handoff. This loop only reads the local journal; it never
|
||||
* retries the HTTP mutation, BLE write, CoreWLAN association or control open.
|
||||
*/
|
||||
export async function awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
initialState: XgridsK1State,
|
||||
idempotencyKey: string,
|
||||
readState: () => Promise<XgridsK1State>,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
assertOperatorIntentCurrent: () => void,
|
||||
options: {
|
||||
now?: () => number;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<XgridsK1State> {
|
||||
const now = options.now ?? Date.now;
|
||||
const wait = options.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
|
||||
globalThis.setTimeout(resolve, delayMs);
|
||||
}));
|
||||
let state = initialState;
|
||||
let operation = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
idempotencyKey,
|
||||
);
|
||||
if (!operation || !["accepted", "running"].includes(operation.status)) {
|
||||
return state;
|
||||
}
|
||||
const deadlineMs = networkProvisionSettlementDeadline(operation, now());
|
||||
|
||||
while (["accepted", "running"].includes(operation.status)) {
|
||||
assertOperatorIntentCurrent();
|
||||
const remainingMs = deadlineMs - now();
|
||||
if (remainingMs <= 0) return state;
|
||||
await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs));
|
||||
assertOperatorIntentCurrent();
|
||||
try {
|
||||
const nextState = await readState();
|
||||
assertOperatorIntentCurrent();
|
||||
acceptState(nextState);
|
||||
state = nextState;
|
||||
} catch (readError) {
|
||||
// A host Wi-Fi transition can briefly abort even a localhost fetch.
|
||||
// Preserve the admitted operation and perform only the next bounded
|
||||
// journal read; the original Apply is never reissued.
|
||||
if (!(readError instanceof ApiError) || !readError.transportUnavailable) {
|
||||
throw readError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
operation = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
idempotencyKey,
|
||||
);
|
||||
if (!operation) return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
const CONTROL_PHASE_WAIT_TIMEOUT_MS = 120_000;
|
||||
|
||||
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||
@@ -642,93 +561,6 @@ function apiErrorForOperation(
|
||||
);
|
||||
}
|
||||
|
||||
const CONNECT_SESSION_RESET_COPY =
|
||||
"Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.";
|
||||
|
||||
function resetConnectSessionMessage(detail: string): string {
|
||||
return `${detail} ${CONNECT_SESSION_RESET_COPY}`;
|
||||
}
|
||||
|
||||
export function networkProvisionFailureMessage(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): string | null {
|
||||
if (!operation || operation.status !== "failed") return null;
|
||||
const code = operation.error?.code;
|
||||
if (typeof code !== "string") return null;
|
||||
|
||||
if (code === "BleakGATTProtocolError") {
|
||||
const attCode = operation.error?.ble_att_error_code;
|
||||
const attName = operation.error?.ble_att_error_name;
|
||||
const attDetail = typeof attCode === "number" && typeof attName === "string"
|
||||
? ` Код периферии: ATT ${attCode} ${attName}.`
|
||||
: "";
|
||||
if (operation.error?.device_write_attempted === false) {
|
||||
return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`;
|
||||
}
|
||||
if (typeof attCode === "number" && typeof attName === "string") {
|
||||
return resetConnectSessionMessage(
|
||||
`Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`,
|
||||
);
|
||||
}
|
||||
return resetConnectSessionMessage(
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.",
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "network-not-found") {
|
||||
const attemptCount = operation.error?.scan_attempt_count;
|
||||
const elapsedMs = operation.error?.scan_elapsed_ms;
|
||||
const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number"
|
||||
? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.`
|
||||
: "";
|
||||
return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`;
|
||||
}
|
||||
|
||||
if (
|
||||
code === "keychain-authorization-required"
|
||||
|| code === "keychain-authorization-denied"
|
||||
|| code === "keychain-authorization-cancelled"
|
||||
|| code === "keychain-access-failed"
|
||||
) {
|
||||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none";
|
||||
return failedBeforeDeviceWrite
|
||||
? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение."
|
||||
: resetConnectSessionMessage(
|
||||
"После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.",
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"host-wifi-operation-timeout":
|
||||
resetConnectSessionMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."),
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.",
|
||||
"profile-credential-source-mismatch":
|
||||
"Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.",
|
||||
"profile-unavailable":
|
||||
resetConnectSessionMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."),
|
||||
"corewlan-error":
|
||||
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
|
||||
"wifi-interface-unavailable":
|
||||
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
|
||||
"unsupported-platform":
|
||||
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
|
||||
"credential-source-unavailable":
|
||||
"Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.",
|
||||
"network-provision-candidate-not-fresh":
|
||||
"Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.",
|
||||
"network-provision-candidate-changed":
|
||||
"Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.",
|
||||
"network-provision-candidate-name-unavailable":
|
||||
"K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.",
|
||||
"network-provision-target-not-distinguishable-from-baseline":
|
||||
resetConnectSessionMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."),
|
||||
"network-provision-lifecycle-busy":
|
||||
"Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.",
|
||||
};
|
||||
return messages[code] ?? null;
|
||||
}
|
||||
|
||||
export function discoveryScanFailureMessage(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): string | null {
|
||||
@@ -1138,7 +970,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const operationId = newOperationId();
|
||||
try {
|
||||
const nextState = await xgridsK1Api.scanBle({
|
||||
duration_seconds: options.durationSeconds ?? 6,
|
||||
duration_seconds: options.durationSeconds ?? BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||
operation_id: operationId,
|
||||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||||
});
|
||||
@@ -1412,143 +1244,36 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
);
|
||||
}
|
||||
};
|
||||
const previous = operationByIdempotencyKey(
|
||||
state,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
if (operationNeedsReconciliation(previous)) {
|
||||
captureFailureReason(previous);
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения не получила подтверждённого результата.",
|
||||
),
|
||||
previous,
|
||||
);
|
||||
}
|
||||
if (operationAllowsFreshProvisioningIntent(previous)) {
|
||||
intentDisposition = "release";
|
||||
captureFailureReason(previous);
|
||||
const failureMessage = networkProvisionFailureMessage(previous);
|
||||
throw apiErrorForOperation(
|
||||
failureMessage
|
||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||||
previous,
|
||||
);
|
||||
}
|
||||
|
||||
let nextState: XgridsK1State;
|
||||
if (previous?.status === "succeeded" && state) {
|
||||
nextState = state;
|
||||
} else try {
|
||||
nextState = await xgridsK1Api.connect({
|
||||
...request,
|
||||
const observation = await observeProvisioningRequest(request, {
|
||||
initialState: latestState.current,
|
||||
send: (input) => xgridsK1Api.connect({
|
||||
...input,
|
||||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||||
});
|
||||
} catch (connectError) {
|
||||
// A failed network action is terminal and persisted by the backend.
|
||||
// Re-read state only; never replay the device command. This replaces
|
||||
// an opaque HTTP 502 banner with the exact host-association outcome.
|
||||
let failedState: XgridsK1State;
|
||||
try {
|
||||
failedState = await xgridsK1Api.getState();
|
||||
} catch {
|
||||
throw connectError;
|
||||
}
|
||||
acceptState(failedState);
|
||||
failedState = await awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
failedState,
|
||||
request.idempotency_key,
|
||||
() => xgridsK1Api.getState(),
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
);
|
||||
const failedOperation = operationByIdempotencyKey(
|
||||
failedState,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
// The response body can be lost after the backend has already
|
||||
// committed the exact idempotent operation. The read-only journal is
|
||||
// authoritative: acknowledge that success instead of presenting a
|
||||
// false failure which could invite another operator click.
|
||||
if (failedOperation?.status === "succeeded") {
|
||||
nextState = failedState;
|
||||
if (
|
||||
!exactAppliedNetworkIntentCompleted(
|
||||
nextState,
|
||||
request,
|
||||
failedOperation,
|
||||
)
|
||||
&& !hasExactConnectionReady(nextState, request.connection_mode)
|
||||
) {
|
||||
throw connectError;
|
||||
}
|
||||
} else {
|
||||
captureFailureReason(failedOperation);
|
||||
if (operationNeedsReconciliation(failedOperation)) {
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
||||
),
|
||||
failedOperation,
|
||||
);
|
||||
}
|
||||
if (operationAllowsFreshProvisioningIntent(failedOperation)) {
|
||||
intentDisposition = "release";
|
||||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
||||
throw apiErrorForOperation(
|
||||
failureMessage
|
||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||||
failedOperation,
|
||||
);
|
||||
}
|
||||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
||||
if (failureMessage) {
|
||||
throw apiErrorForOperation(failureMessage, failedOperation);
|
||||
}
|
||||
if (failedOperation) {
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения не получила подтверждённого результата.",
|
||||
),
|
||||
failedOperation,
|
||||
);
|
||||
}
|
||||
throw connectError;
|
||||
}
|
||||
}
|
||||
const operation = operationByIdempotencyKey(
|
||||
nextState,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
if (operationNeedsReconciliation(operation)) {
|
||||
captureFailureReason(operation);
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
||||
),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
if (operationAllowsFreshProvisioningIntent(operation)) {
|
||||
intentDisposition = "release";
|
||||
}),
|
||||
readState: () => xgridsK1Api.getState(),
|
||||
acceptState,
|
||||
currentState: () => latestState.current,
|
||||
assertCurrent: assertOperatorIntentCurrent,
|
||||
});
|
||||
// Return the same authoritative snapshot on failure and success. The
|
||||
// form must not reconstruct an outcome from an empty result object.
|
||||
observedState = observation.state;
|
||||
let nextState = observation.state;
|
||||
const operation = observation.operation;
|
||||
if (operation?.status !== "succeeded") {
|
||||
captureFailureReason(operation);
|
||||
intentDisposition = operationAllowsFreshProvisioningIntent(operation)
|
||||
? "release"
|
||||
: "retain";
|
||||
const failureMessage = networkProvisionFailureMessage(operation);
|
||||
throw apiErrorForOperation(
|
||||
failureMessage
|
||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
if (operation && operation.status !== "succeeded") {
|
||||
captureFailureReason(operation);
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения завершилась без подтверждения успеха.",
|
||||
),
|
||||
operation,
|
||||
if (operation) {
|
||||
throw apiErrorForOperation(
|
||||
failureMessage ?? "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
throw observation.transportError ?? new ApiError(
|
||||
"Сервис не подтвердил результат подключения. Обновите состояние; команда автоматически не повторяется.",
|
||||
);
|
||||
}
|
||||
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
|
||||
|
||||
Reference in New Issue
Block a user