feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
@@ -19,10 +18,6 @@ import { useXgridsK1Controller } from "./runtimeContext";
|
||||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, error, refresh, clearError } = controller;
|
||||
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
|
||||
setProfileConfirmed(confirmed);
|
||||
}, []);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
@@ -51,7 +46,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||
@@ -79,13 +74,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
profileConfirmed={profileConfirmed}
|
||||
onProfileConfirmedChange={updateProfileConfirmation}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
profileConfirmed={profileConfirmed}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
|
||||
@@ -138,7 +138,34 @@ export interface XgridsApplicationControlSession {
|
||||
outcome_unknown: boolean;
|
||||
failure?: {
|
||||
code?: string;
|
||||
reason_code?: string;
|
||||
message?: string;
|
||||
failed_phase?: string | null;
|
||||
dialogue_stage?: string | null;
|
||||
transport_state?: string | null;
|
||||
publish_attempts?: number | null;
|
||||
qos2_completions?: number | null;
|
||||
correlated_responses?: number | null;
|
||||
ignored_known_responses?: number | null;
|
||||
late_known_responses?: number | null;
|
||||
modeling_command_attempted?: boolean | null;
|
||||
diagnostic_snapshot_unavailable?: string[];
|
||||
diagnostic_evidence_unavailable?: string[];
|
||||
correlation_failure?: {
|
||||
phase?: string;
|
||||
operation_key?: string;
|
||||
response_topic?: string;
|
||||
reason_code?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
compatibility_failure?: {
|
||||
phase?: string;
|
||||
operation_key?: string;
|
||||
response_topic?: string;
|
||||
reason_code?: string;
|
||||
expected?: Record<string, unknown>;
|
||||
observed?: Record<string, unknown>;
|
||||
} | null;
|
||||
safe_to_retry?: boolean;
|
||||
} | null;
|
||||
dialogue?: Record<string, unknown> | null;
|
||||
@@ -153,6 +180,8 @@ export interface XgridsAcquisition {
|
||||
compatibility_profile_id: string;
|
||||
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
||||
project_name?: string | null;
|
||||
mount_type?: "handheld" | null;
|
||||
gnss_mode?: "none" | null;
|
||||
cleanup_pending?: boolean;
|
||||
requested_streams: string[];
|
||||
target_host: string;
|
||||
@@ -260,6 +289,7 @@ export interface XgridsK1State {
|
||||
devices?: BleDevice[];
|
||||
selected_device_id?: string | null;
|
||||
k1_ip?: string | null;
|
||||
connection_mode?: "bridge" | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
rerun_grpc_url?: string | null;
|
||||
@@ -293,13 +323,14 @@ export interface ScanRequest {
|
||||
export interface CompatibilityAttestation {
|
||||
firmware_version: "3.0.2";
|
||||
topology: "direct-lan";
|
||||
operator_confirmed: true;
|
||||
verification: "live-device-info";
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
connection_mode: "bridge";
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
@@ -307,6 +338,8 @@ export interface ConnectRequest {
|
||||
|
||||
export interface PrepareAcquisitionRequest {
|
||||
project_name: string;
|
||||
mount_type: "handheld";
|
||||
gnss_mode: "none";
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
requested_streams?: RequestedStreamId[];
|
||||
@@ -398,11 +431,13 @@ export interface EnterApplicationWorkspaceRequest {
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly transportUnavailable: boolean;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
constructor(message: string, status = 0, transportUnavailable = false) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.transportUnavailable = transportUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +468,11 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
||||
throw new ApiError(
|
||||
"Не удалось подключиться к локальному сервису устройства.",
|
||||
0,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CompatibilityAttestation } from "./api";
|
||||
|
||||
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({
|
||||
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
operator_confirmed: true,
|
||||
verification: "live-device-info",
|
||||
});
|
||||
|
||||
@@ -4,13 +4,22 @@ import {
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||
import {
|
||||
SUPPORTED_GNSS_MODE,
|
||||
SUPPORTED_MOUNT_TYPE,
|
||||
gnssModeOptions,
|
||||
mountTypeOptions,
|
||||
type GnssMode,
|
||||
type MountType,
|
||||
} from "../configuration";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
@@ -41,12 +50,10 @@ const PHYSICAL_ACCEPTANCE = {
|
||||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
profileConfirmed,
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
profileConfirmed: boolean;
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
@@ -57,7 +64,6 @@ export function K1AcquisitionPipeline({
|
||||
startCanonicalAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
@@ -66,7 +72,8 @@ export function K1AcquisitionPipeline({
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
|
||||
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
@@ -125,8 +132,6 @@ export function K1AcquisitionPipeline({
|
||||
const startLive = async () => {
|
||||
setProjectNameTouched(true);
|
||||
if (
|
||||
!profileConfirmed ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
@@ -142,7 +147,9 @@ export function K1AcquisitionPipeline({
|
||||
},
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||
},
|
||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||
}),
|
||||
@@ -181,6 +188,30 @@ export function K1AcquisitionPipeline({
|
||||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<div className="scan-configuration-grid">
|
||||
<div className="configuration-field">
|
||||
<span className="nodedc-field__description">Тип установки / носитель</span>
|
||||
<Select
|
||||
label="Тип установки / носитель"
|
||||
value={mountType}
|
||||
options={mountTypeOptions}
|
||||
onChange={setMountType}
|
||||
disabled={isBusy || sessionLocked}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
<div className="configuration-field">
|
||||
<span className="nodedc-field__description">Режим GNSS</span>
|
||||
<Select
|
||||
label="Режим GNSS"
|
||||
value={gnssMode}
|
||||
options={gnssModeOptions}
|
||||
onChange={setGnssMode}
|
||||
disabled={isBusy || sessionLocked}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Имя войдёт в единственный канонический START"
|
||||
@@ -198,19 +229,12 @@ export function K1AcquisitionPipeline({
|
||||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Checker
|
||||
checked={physicalAcceptanceConfirmed}
|
||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||
onChange={setPhysicalAcceptanceConfirmed}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
@@ -232,6 +256,9 @@ export function K1AcquisitionPipeline({
|
||||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
</Button>
|
||||
<p className="start-confirmation-note">
|
||||
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
|
||||
</p>
|
||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -242,9 +269,7 @@ export function K1AcquisitionPipeline({
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
|
||||
: controlPhase === "failed"
|
||||
{controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||
@@ -256,7 +281,7 @@ export function K1AcquisitionPipeline({
|
||||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,15 +307,6 @@ export function K1AcquisitionPipeline({
|
||||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
{control?.can_confirm_standby ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
|
||||
@@ -4,13 +4,19 @@ import {
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||
import {
|
||||
SUPPORTED_CONNECTION_MODE,
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
} from "../configuration";
|
||||
import { provisioningIntentKey } from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
@@ -79,30 +85,28 @@ export function K1ProvisioningPipeline({
|
||||
controller,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
profileConfirmed,
|
||||
onProfileConfirmedChange,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
phaseLabel: string;
|
||||
phaseTone: StatusTone;
|
||||
profileConfirmed: boolean;
|
||||
onProfileConfirmedChange: (confirmed: boolean) => void;
|
||||
}) {
|
||||
const { state, pendingAction, scan, connect } = controller;
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
|
||||
SUPPORTED_CONNECTION_MODE,
|
||||
);
|
||||
const provisioningIntentRef = useRef<string | null>(null);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && profileConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
onProfileConfirmedChange(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
@@ -111,15 +115,14 @@ export function K1ProvisioningPipeline({
|
||||
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const resetProfile = () => {
|
||||
onProfileConfirmedChange(false);
|
||||
const resetProvisioningIntent = () => {
|
||||
provisioningIntentRef.current = null;
|
||||
};
|
||||
|
||||
@@ -131,7 +134,8 @@ export function K1ProvisioningPipeline({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
connection_mode: SUPPORTED_CONNECTION_MODE,
|
||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||
idempotency_key: idempotencyKey,
|
||||
});
|
||||
if (succeeded) {
|
||||
@@ -146,6 +150,22 @@ export function K1ProvisioningPipeline({
|
||||
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span><h2>Подключите устройство к сети</h2></div>
|
||||
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
|
||||
</header>
|
||||
<div className="configuration-anchor">
|
||||
<span className="nodedc-field__description">
|
||||
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
|
||||
</span>
|
||||
<Select
|
||||
label="Способ подключения"
|
||||
value={connectionMode}
|
||||
options={connectionModeOptions}
|
||||
onChange={(value) => {
|
||||
setConnectionMode(value);
|
||||
resetProvisioningIntent();
|
||||
}}
|
||||
disabled={isBusy}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
<div className="wizard-list">
|
||||
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
|
||||
<div className="nodedc-field">
|
||||
@@ -153,7 +173,7 @@ export function K1ProvisioningPipeline({
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProfile(); }}
|
||||
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
@@ -163,13 +183,13 @@ export function K1ProvisioningPipeline({
|
||||
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
||||
>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.</p>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => { setSelectedDeviceId(""); resetProfile(); void scan(); }}
|
||||
onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
|
||||
>
|
||||
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
|
||||
</Button>
|
||||
@@ -179,21 +199,18 @@ export function K1ProvisioningPipeline({
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => { setSelectedDeviceId(device.device_id); resetProfile(); }}
|
||||
onSelect={() => { setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
|
||||
/>
|
||||
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
|
||||
</div>
|
||||
</WizardStep>
|
||||
<WizardStep number="03" title="Передайте настройки Wi‑Fi" status={state?.k1_ip ? "Подключено" : "Не подключено"} tone={state?.k1_ip ? "success" : "neutral"}>
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки общей сети"
|
||||
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
|
||||
tone={state?.k1_ip ? "warning" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Mission Core не определяет прошивку автоматически. Подтвердите только точное соответствие профилю.</span>
|
||||
<Checker
|
||||
checked={profileConfirmed}
|
||||
label="Я вручную подтвердил FW 3.0.2 и direct-LAN"
|
||||
onChange={(checked) => { onProfileConfirmedChange(checked); provisioningIntentRef.current = null; }}
|
||||
/>
|
||||
</div>
|
||||
<TextField label="Название сети Wi‑Fi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
@@ -201,7 +218,7 @@ export function K1ProvisioningPipeline({
|
||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
@@ -72,7 +72,7 @@ function phasePresentation(
|
||||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
@@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
|
||||
const { state, pendingAction, stop } = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
@@ -179,16 +179,6 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
{state?.application_control_session?.can_confirm_standby ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SelectOption } from "@nodedc/ui-react";
|
||||
|
||||
export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
|
||||
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
||||
export type GnssMode = "none" | "rtk" | "ppk";
|
||||
|
||||
export const SUPPORTED_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;
|
||||
|
||||
export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
|
||||
{
|
||||
value: "bridge",
|
||||
label: "Общая сеть · Bridge",
|
||||
description: "K1 и Mission Core работают в одной локальной сети. Подтверждённый путь.",
|
||||
},
|
||||
{
|
||||
value: "quick-connect",
|
||||
label: "Точка доступа K1 · Quick Connect",
|
||||
description: "Mission Core подключается к сети сканера. Будет доступно после отдельной приёмки.",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "direct-connect",
|
||||
label: "Хотспот контроллера · Direct Connect",
|
||||
description: "K1 подключается к сети управляющего устройства. Будет доступно после отдельной приёмки.",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mountTypeOptions: Array<SelectOption<MountType>> = [
|
||||
{
|
||||
value: "handheld",
|
||||
label: "Ручной",
|
||||
description: "Подтверждённый канонический профиль MountType.HANDHELD.",
|
||||
},
|
||||
{
|
||||
value: "vehicle-mounted",
|
||||
label: "На наземной платформе",
|
||||
description: "Vehicle-Mounted. Недоступно до отдельной приёмки START/STOP.",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "uav",
|
||||
label: "На БПЛА",
|
||||
description: "Drone/UAV. Недоступно до отдельной приёмки START/STOP.",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "backpack",
|
||||
label: "Ранцевый",
|
||||
description: "Backpack. Недоступно до отдельной приёмки START/STOP.",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const gnssModeOptions: Array<SelectOption<GnssMode>> = [
|
||||
{
|
||||
value: "none",
|
||||
label: "Без RTK",
|
||||
description: "RTK/NTRIP читаются только для обнаружения возможностей и не включаются.",
|
||||
},
|
||||
{
|
||||
value: "rtk",
|
||||
label: "Использовать RTK",
|
||||
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "ppk",
|
||||
label: "Использовать PPK",
|
||||
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
@@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
|
||||
|
||||
import type {
|
||||
AcquisitionState,
|
||||
XgridsApplicationControlPhase,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
XgridsOperation,
|
||||
@@ -22,6 +23,25 @@ const FAILED_OPERATION_STATUSES = new Set([
|
||||
]);
|
||||
|
||||
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
|
||||
export type ControlSessionEntryPlan =
|
||||
| "open"
|
||||
| "continue"
|
||||
| "failed"
|
||||
| "duplicate-open";
|
||||
|
||||
export function controlSessionEntryPlan(
|
||||
phase: XgridsApplicationControlPhase,
|
||||
openedByCurrentOperatorAction: boolean,
|
||||
canOpen: boolean,
|
||||
): ControlSessionEntryPlan {
|
||||
if (phase === "failed") {
|
||||
return !openedByCurrentOperatorAction && canOpen ? "open" : "failed";
|
||||
}
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
return openedByCurrentOperatorAction ? "duplicate-open" : "open";
|
||||
}
|
||||
return "continue";
|
||||
}
|
||||
|
||||
export function isTerminalAcquisitionState(
|
||||
state: AcquisitionState | null | undefined,
|
||||
@@ -119,7 +139,12 @@ export function normalizeRuntimePhase(
|
||||
}
|
||||
if (acquisitionState === "prepared") return "connected";
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "connected") return "connected";
|
||||
if (phase === "connected") {
|
||||
const controlPhase = state?.application_control_session?.state;
|
||||
return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
|
||||
? "connected"
|
||||
: "configuring";
|
||||
}
|
||||
if (phase === "starting_live") return "starting";
|
||||
if (phase === "live") return "streaming";
|
||||
if (phase === "replay") return "replaying";
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
[
|
||||
/^control MQTT connect call failed(?::.*)?$/gi,
|
||||
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||
],
|
||||
[
|
||||
/macOS Keychain authority is unavailable/gi,
|
||||
"Локальный допуск управления K1 не подготовлен. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/macOS Keychain authority lookup failed/gi,
|
||||
"Локальный допуск управления K1 недоступен. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/control MQTT connect call failed/gi,
|
||||
"Управляющее соединение с K1 не открылось. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/^Device plugin returned an invalid non-JSON action result$/gi,
|
||||
"Плагин устройства сформировал некорректное внутреннее состояние. Запрос к K1 не повторялся.",
|
||||
],
|
||||
[
|
||||
/^bootstrap response correlation failed.*$/gi,
|
||||
"Ответ K1 на подготовительном этапе не совпал с ожидаемой операцией. Диалог остановлен без автоматического повтора.",
|
||||
],
|
||||
[
|
||||
/^live DeviceInfo is incompatible with the selected.*$/gi,
|
||||
"Устройство ответило, но его живые данные не соответствуют выбранному профилю совместимости. START и STOP не отправлялись.",
|
||||
],
|
||||
[
|
||||
/^K1 did not confirm bound SCANNING initialization.*$/gi,
|
||||
"После START устройство не подтвердило завершение инициализации в безопасный срок. Автоматический STOP не отправлялся.",
|
||||
],
|
||||
[
|
||||
/^stale control response makes the next command outcome ambiguous$/gi,
|
||||
"Получен запоздавший ответ предыдущего этапа. Диалог остановлен, следующая команда не отправлялась.",
|
||||
],
|
||||
[
|
||||
/control MQTT connection\/subscription timed out/gi,
|
||||
"Управляющее соединение с K1 не подтвердилось вовремя. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface RuntimeGenerationToken {
|
||||
readonly runtimeGeneration: number;
|
||||
}
|
||||
|
||||
export interface OperatorIntentToken extends RuntimeGenerationToken {
|
||||
readonly intentGeneration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates asynchronous UI work across both plugin activation changes and
|
||||
* successive explicit operator intents.
|
||||
*
|
||||
* A boolean "mounted" flag is insufficient here: an operation started before
|
||||
* active=true -> false -> true would otherwise observe true again and resume.
|
||||
*/
|
||||
export class OperatorIntentGeneration {
|
||||
private runtimeGeneration = 0;
|
||||
private intentGeneration = 0;
|
||||
private active = false;
|
||||
|
||||
activateRuntime(): RuntimeGenerationToken {
|
||||
this.runtimeGeneration += 1;
|
||||
this.active = true;
|
||||
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
|
||||
}
|
||||
|
||||
deactivateRuntime(): void {
|
||||
if (!this.active) return;
|
||||
this.runtimeGeneration += 1;
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
captureRuntime(): RuntimeGenerationToken | null {
|
||||
if (!this.active) return null;
|
||||
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
|
||||
}
|
||||
|
||||
beginOperatorIntent(): OperatorIntentToken | null {
|
||||
if (!this.active) return null;
|
||||
this.intentGeneration += 1;
|
||||
return Object.freeze({
|
||||
runtimeGeneration: this.runtimeGeneration,
|
||||
intentGeneration: this.intentGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
isRuntimeCurrent(token: RuntimeGenerationToken): boolean {
|
||||
return this.active && token.runtimeGeneration === this.runtimeGeneration;
|
||||
}
|
||||
|
||||
isOperatorIntentCurrent(token: OperatorIntentToken): boolean {
|
||||
return (
|
||||
this.isRuntimeCurrent(token)
|
||||
&& token.intentGeneration === this.intentGeneration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function awaitWhileIntentCurrent<T>(
|
||||
assertCurrent: () => void,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
assertCurrent();
|
||||
const result = await operation();
|
||||
assertCurrent();
|
||||
return result;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const phaseLabels: Record<string, string> = {
|
||||
device_selected: "Устройство выбрано",
|
||||
provisioning: "Передача настроек Wi‑Fi",
|
||||
connecting: "Подключение",
|
||||
connected: "Устройство подключено",
|
||||
connected: "Сетевой адрес устройства получен",
|
||||
starting_live: "Запуск потока",
|
||||
live: "Поток в реальном времени",
|
||||
replay: "Повтор записи",
|
||||
@@ -25,7 +25,8 @@ export function phaseLabel(phase: string | null | undefined): string {
|
||||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
if (!phase) return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
||||
if (["live", "replay"].includes(phase)) return "success";
|
||||
if (phase === "connected") return "warning";
|
||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
@@ -69,6 +69,20 @@
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.configuration-anchor {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1.2rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.028);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.configuration-anchor .nodedc-select-anchor,
|
||||
.configuration-field .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||
@@ -282,6 +296,25 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.scan-configuration-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.configuration-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.start-confirmation-note {
|
||||
margin: -0.25rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||
}
|
||||
@@ -435,6 +468,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.xgrids-k1-plugin .scan-configuration-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
controlSessionEntryPlan,
|
||||
isTerminalAcquisitionState,
|
||||
liveStartPlan,
|
||||
operationByIdempotencyKey,
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
isSoftwareCommandedAcquisition,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
awaitWhileIntentCurrent,
|
||||
OperatorIntentGeneration,
|
||||
} from "./operatorIntentGeneration";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
|
||||
export type PendingAction =
|
||||
@@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||
|
||||
function controlFailure(state: XgridsK1State): ApiError {
|
||||
const failure = state.application_control_session?.failure;
|
||||
const localizedDetail = localizeRuntimeMessage(failure?.message);
|
||||
const reasonLabels: Record<string, string> = {
|
||||
application_authority_unavailable:
|
||||
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
|
||||
mqtt_connect_call_failed:
|
||||
"Управляющее MQTT-соединение со сканером не открылось.",
|
||||
mqtt_connect_rejected:
|
||||
"Сканер отклонил управляющее MQTT-соединение.",
|
||||
mqtt_connection_timeout:
|
||||
"Подключение или подписки MQTT не подтвердились вовремя.",
|
||||
mqtt_subscription_failed:
|
||||
"Сканер не подтвердил канонические MQTT-подписки.",
|
||||
mqtt_response_timeout:
|
||||
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
|
||||
response_identity_decode_failed:
|
||||
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
|
||||
modeling_response_decode_failed:
|
||||
"Ответ START/STOP не удалось безопасно разобрать.",
|
||||
duplicate_application_response:
|
||||
"Сканер прислал повторный ответ на уже завершённую операцию.",
|
||||
unexpected_response_identity:
|
||||
"Получен ответ неизвестной операции; диалог остановлен.",
|
||||
response_identity_mismatch:
|
||||
"Идентичность ответа не совпала с ожидаемой операцией.",
|
||||
response_session_mismatch:
|
||||
"Session ответа не совпал с точной подготовительной операцией.",
|
||||
response_device_identity_mismatch:
|
||||
"Ответ относится не к тому экземпляру устройства.",
|
||||
response_authority_mismatch:
|
||||
"Ответ не совпал с локальным допуском приложения.",
|
||||
response_rejected:
|
||||
"Сканер отклонил подготовительную операцию.",
|
||||
compatibility_profile_mismatch:
|
||||
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
|
||||
scan_initialization_timeout:
|
||||
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
|
||||
operation_reuse_forbidden:
|
||||
"Повтор уже использованной операции заблокирован.",
|
||||
};
|
||||
const reasonDetail = failure?.reason_code
|
||||
? reasonLabels[failure.reason_code]
|
||||
: undefined;
|
||||
const stageLabels: Record<string, string> = {
|
||||
connecting: "подключение и первичный диалог",
|
||||
connection: "первичный диалог",
|
||||
"connection-ready": "подключение подтверждено",
|
||||
"workspace-requested": "вход в рабочую область",
|
||||
"workspace-ready": "рабочая область готова",
|
||||
"project-requested": "подготовка проекта",
|
||||
"project-ready": "проект готов",
|
||||
"start-requested": "подтверждение запуска",
|
||||
"start-attempted": "команда запуска",
|
||||
initializing: "калибровка оборудования",
|
||||
scanning: "сканирование",
|
||||
"stop-requested": "подтверждение остановки",
|
||||
"stop-attempted": "команда остановки",
|
||||
stopping: "остановка",
|
||||
};
|
||||
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
|
||||
const stage = stageCode
|
||||
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
|
||||
: "этап не зафиксирован";
|
||||
const commandStatus = failure?.modeling_command_attempted === true
|
||||
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
|
||||
: failure?.modeling_command_attempted === false
|
||||
? "Команды START и STOP не отправлялись."
|
||||
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
|
||||
const retryStatus = failure?.safe_to_retry
|
||||
? "Новая попытка возможна только отдельным нажатием оператора."
|
||||
: "Повтор заблокирован до ручной проверки состояния.";
|
||||
const exchanges = typeof failure?.publish_attempts === "number"
|
||||
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
|
||||
: "Количество MQTT-публикаций не зафиксировано.";
|
||||
const failedProtocolEvidence =
|
||||
failure?.compatibility_failure ?? failure?.correlation_failure;
|
||||
const failedOperation = failedProtocolEvidence?.operation_key
|
||||
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
|
||||
: "";
|
||||
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
|
||||
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
|
||||
: "";
|
||||
return new ApiError(
|
||||
failure?.message
|
||||
? `Канонический диалог K1 остановлен: ${failure.message}`
|
||||
: "Канонический диалог K1 остановлен до запуска сканирования.",
|
||||
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForControlPhase(
|
||||
expected: XgridsApplicationControlPhase,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
assertOperatorIntentCurrent: () => void,
|
||||
): Promise<XgridsK1State> {
|
||||
for (;;) {
|
||||
const nextState = await xgridsK1Api.getState();
|
||||
assertOperatorIntentCurrent();
|
||||
const nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.getState(),
|
||||
);
|
||||
acceptState(nextState);
|
||||
const phase = controlPhase(nextState);
|
||||
if (phase === expected) return nextState;
|
||||
@@ -74,15 +163,22 @@ async function waitForControlPhase(
|
||||
}
|
||||
// This cadence only reads local server state. It never schedules, retries,
|
||||
// or times a K1 command; every next write remains gated by device response.
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
});
|
||||
await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
// Domain errors are already written for the operator. Only HTTP details
|
||||
// originate at the backend and need vendor/runtime normalization.
|
||||
const message = error.status
|
||||
? localizeRuntimeMessage(error.message) ?? error.message
|
||||
: error.message;
|
||||
return error.status
|
||||
? `${message} (HTTP ${error.status})`
|
||||
: message;
|
||||
@@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
const actionInFlight = useRef(false);
|
||||
const operatorIntents = useRef(new OperatorIntentGeneration());
|
||||
const actionSequence = useRef(0);
|
||||
const actionInFlight = useRef<{
|
||||
runtimeGeneration: number;
|
||||
actionSequence: number;
|
||||
} | null>(null);
|
||||
|
||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||
@@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
if (!enabled) return;
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return;
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
xgridsK1Api.getHealth(),
|
||||
xgridsK1Api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
@@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||
if (!enabled) return false;
|
||||
if (actionInFlight.current) return false;
|
||||
actionInFlight.current = true;
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return false;
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration
|
||||
=== runtimeToken.runtimeGeneration
|
||||
) return false;
|
||||
actionSequence.current += 1;
|
||||
const actionToken = {
|
||||
runtimeGeneration: runtimeToken.runtimeGeneration,
|
||||
actionSequence: actionSequence.current,
|
||||
};
|
||||
actionInFlight.current = actionToken;
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
const nextState = await operation();
|
||||
if (mounted.current) acceptState(nextState);
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (mounted.current) {
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
setError(messageFor(operationError));
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
if (
|
||||
operationError instanceof ApiError
|
||||
&& operationError.transportUnavailable
|
||||
) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
actionInFlight.current = false;
|
||||
if (mounted.current) setPendingAction(null);
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
|
||||
&& actionInFlight.current.actionSequence === actionToken.actionSequence
|
||||
) {
|
||||
actionInFlight.current = null;
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[acceptState, enabled],
|
||||
@@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const startCanonicalAcquisition = useCallback(
|
||||
(request: CanonicalLiveStartRequest) =>
|
||||
run("live", async () => {
|
||||
let nextState = await xgridsK1Api.getState();
|
||||
const intentToken = operatorIntents.current.beginOperatorIntent();
|
||||
if (!intentToken) {
|
||||
throw new ApiError(
|
||||
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
|
||||
);
|
||||
}
|
||||
const assertOperatorIntentCurrent = () => {
|
||||
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
|
||||
throw new ApiError(
|
||||
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.getState(),
|
||||
);
|
||||
let openedControlSession = false;
|
||||
acceptState(nextState);
|
||||
const plan = liveStartPlan(nextState);
|
||||
if (plan === "blocked") {
|
||||
@@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
if (plan === "already-running") return nextState;
|
||||
|
||||
for (;;) {
|
||||
assertOperatorIntentCurrent();
|
||||
const phase = controlPhase(nextState);
|
||||
const acquisition = nextState.acquisition;
|
||||
const entryPlan = controlSessionEntryPlan(
|
||||
phase,
|
||||
openedControlSession,
|
||||
nextState.application_control_session?.can_open === true,
|
||||
);
|
||||
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
if (entryPlan === "failed") {
|
||||
// A failed dialogue always ends this operator intent. Even when
|
||||
// backend reconciliation says a fresh attempt may be safe, that
|
||||
// attempt requires another explicit click.
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
|
||||
if (entryPlan === "duplicate-open") {
|
||||
throw new ApiError(
|
||||
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
|
||||
);
|
||||
}
|
||||
|
||||
if (entryPlan === "open") {
|
||||
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
||||
throw new ApiError(
|
||||
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
||||
);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "failed") {
|
||||
if (nextState.application_control_session?.can_open !== true) {
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
openedControlSession = true;
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.openApplicationControlSession(request.control),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connecting") {
|
||||
nextState = await waitForControlPhase("connection-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"connection-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connection-ready") {
|
||||
nextState = await xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
});
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
}),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-requested") {
|
||||
nextState = await waitForControlPhase("workspace-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"workspace-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-ready") {
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.prepareAcquisition(request.acquisition),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
@@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
||||
);
|
||||
}
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "project-requested") {
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
if (!acquisition || acquisition.state !== "prepared") {
|
||||
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
||||
}
|
||||
nextState = await xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
});
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
}),
|
||||
);
|
||||
acceptState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
@@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const confirmStoppedAtSteadyGreen = useCallback(
|
||||
() =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
|
||||
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
|
||||
}
|
||||
const stopOperation = [...(state?.operations ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(operation) =>
|
||||
operation.action === "acquisition.stop" &&
|
||||
operation.status === "operator_action_required",
|
||||
);
|
||||
if (!stopOperation) {
|
||||
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
|
||||
}
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "graceful",
|
||||
operator_confirmed: true,
|
||||
operation_id: stopOperation.operation_id,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
@@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
mounted.current = true;
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
setState(null);
|
||||
setBackendStatus("checking");
|
||||
setEventStatus("closed");
|
||||
@@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
mounted.current = true;
|
||||
operatorIntents.current.activateRuntime();
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
@@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
setObservationSourceActive,
|
||||
updateViewerSettings,
|
||||
|
||||
@@ -366,6 +366,23 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
|
||||
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
|
||||
raise CompatibilityProfileError("acquisition control must remain operator-manual")
|
||||
|
||||
acceptance_transport = _object(
|
||||
control.get("software_acceptance_transport"),
|
||||
"$.acquisition_control.software_acceptance_transport",
|
||||
)
|
||||
if acceptance_transport != {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": False,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none",
|
||||
}:
|
||||
raise CompatibilityProfileError(
|
||||
"software acceptance transport differs from the reviewed operator-present contract"
|
||||
)
|
||||
|
||||
device_control = _object(
|
||||
control.get("verified_device_control"),
|
||||
"$.acquisition_control.verified_device_control",
|
||||
@@ -497,6 +514,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
||||
scope = _object(root.get("scope"), "$.scope")
|
||||
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
|
||||
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
|
||||
if scope.get("platform_type") != "A4":
|
||||
raise CompatibilityProfileError("profile platform type must remain the observed A4")
|
||||
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
||||
if firmware != {"match": "exact", "version": "3.0.2"}:
|
||||
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scope": {
|
||||
"vendor": "XGRIDS",
|
||||
"model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"firmware": {
|
||||
"match": "exact",
|
||||
"version": "3.0.2"
|
||||
@@ -26,8 +27,8 @@
|
||||
"request_topic_subscription_enabled": false,
|
||||
"notes": [
|
||||
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
|
||||
"The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.",
|
||||
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.",
|
||||
"The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
|
||||
"Observed LixelGO modeling requests remain write-disabled in this descriptive profile; the separately installed acceptance transport requires a live DeviceInfo match and one operator-present action permit.",
|
||||
"Unknown firmware, transport, topics, fields, and action responses fail closed."
|
||||
]
|
||||
},
|
||||
@@ -316,6 +317,15 @@
|
||||
"acquisition_control": {
|
||||
"mode": "operator-manual",
|
||||
"write_enabled": false,
|
||||
"software_acceptance_transport": {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": false,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none"
|
||||
},
|
||||
"verified_device_control": {
|
||||
"gesture": "physical-double-click",
|
||||
"state_dependent_result": "start from steady-green standby; stop during active scanning",
|
||||
@@ -359,7 +369,7 @@
|
||||
},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||
"authorization policy for any setting outside the retained request",
|
||||
"timeout, rejection and rollback contract"
|
||||
],
|
||||
@@ -406,7 +416,7 @@
|
||||
"request_fields": {},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||
"save-completion and final-standby state mapping",
|
||||
"timeout and rollback contract"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user