feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user