feat(k1): complete canonical control lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:02 +03:00
parent d7a2c22faf
commit ffffee1879
42 changed files with 3577 additions and 556 deletions
@@ -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>
);
}