feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions
@@ -87,6 +87,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
controller={controller}
profileConfirmed={profileConfirmed}
openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/>
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
</div>
+12
View File
@@ -74,6 +74,8 @@ export interface XgridsAcquisition {
device_session_id: string;
compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
project_name?: string | null;
cleanup_pending?: boolean;
requested_streams: string[];
target_host: string;
duration_seconds: number;
@@ -119,6 +121,13 @@ export interface XgridsK1Metrics {
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
device_elapsed_seconds?: number | null;
device_route_distance_meters?: number | null;
device_speed_meters_per_second?: number | null;
device_speed_mps?: number | null;
elapsed_seconds?: number | null;
route_distance_meters?: number | null;
speed_meters_per_second?: number | null;
[key: string]: number | null | undefined;
}
@@ -216,6 +225,7 @@ export interface ConnectRequest {
}
export interface PrepareAcquisitionRequest {
project_name: string;
host?: string;
duration_seconds?: number;
requested_streams?: RequestedStreamId[];
@@ -229,6 +239,7 @@ export interface PrepareAcquisitionRequest {
export type RequestedStreamId =
| "spatial.point-cloud.live"
| "spatial.pose.live"
| "device.modeling.live"
| "device.status.live"
| "device.heartbeat.live";
@@ -257,6 +268,7 @@ export interface AbortAcquisitionRequest {
}
export interface CompatibilityLiveRequest {
project_name: string;
host?: string;
duration_seconds?: number;
compatibility_attestation: CompatibilityAttestation;
@@ -0,0 +1,11 @@
export async function runAutomaticSpatialSourceStart(
start: () => Promise<boolean>,
activateAutomaticSpatialSource: () => void,
openSpatialScene: () => void,
): Promise<boolean> {
const started = await start();
if (!started) return false;
activateAutomaticSpatialSource();
openSpatialScene();
return true;
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Checker,
@@ -11,12 +11,15 @@ import {
} from "@nodedc/ui-react";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
isConfirmedLiveState,
isSourceRuntimeBusy,
isVendorWriteCapable,
recoverableAcquisition,
sourceStatusLabel,
} from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
type SessionIntent = "live" | "replay";
@@ -30,10 +33,12 @@ export function K1AcquisitionPipeline({
controller,
profileConfirmed,
openSpatialScene,
activateAutomaticSpatialSource,
}: {
controller: XgridsK1Controller;
profileConfirmed: boolean;
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}) {
const {
state,
@@ -44,10 +49,18 @@ export function K1AcquisitionPipeline({
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [projectName, setProjectName] = useState("");
const [projectNameTouched, setProjectNameTouched] = useState(false);
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName);
const vendorWriteCapable = isVendorWriteCapable(state);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -57,9 +70,15 @@ export function K1AcquisitionPipeline({
}
}, [state?.acquisition?.state, state?.source_mode]);
useEffect(() => {
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
hydratedAcquisitionId.current = acquisitionId;
setProjectName(preparedAcquisition?.project_name ?? "");
setProjectNameTouched(false);
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
const isBusy = pendingAction !== null;
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
const effectiveSessionIntent: SessionIntent =
@@ -85,31 +104,39 @@ export function K1AcquisitionPipeline({
);
const submitLive = async () => {
if (!profileConfirmed || sourceRuntimeBusy) return;
setProjectNameTouched(true);
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim();
const started = await prepareAndStartAcquisition({
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
if (started) openSpatialScene();
await runAutomaticSpatialSourceStart(
() => prepareAndStartAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
}),
activateAutomaticSpatialSource,
openSpatialScene,
);
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) openSpatialScene();
await runAutomaticSpatialSourceStart(
() => startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
}),
activateAutomaticSpatialSource,
openSpatialScene,
);
};
return (
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
</header>
@@ -121,27 +148,51 @@ export function K1AcquisitionPipeline({
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Название проекта"
hint="Обязательное поле · до 96 символов"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={sessionLocked}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
placeholder="Например, Испытание маршрута 01"
/>
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
disabled={sessionLocked}
spellCheck={false}
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !liveTargetReady || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Подготавливаем приём…" : preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить приём данных"}
{pendingAction === "live"
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
: vendorWriteCapable
? "Инициировать приём данных и работу устройства"
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
</Button>
<p className="live-instruction">
{!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
? vendorWriteCapable
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
</p>
</div>
@@ -163,7 +214,9 @@ export function K1AcquisitionPipeline({
{state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>
@@ -0,0 +1,164 @@
import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type { AcquisitionState, XgridsAcquisition } from "../api";
import {
isSoftwareCommandedAcquisition,
shouldRenderSpatialControls,
} from "../lifecycle";
import {
deviceTelemetry,
formatNumber,
spatialActionFailure,
} from "../presentation";
import { useXgridsK1Controller } from "../runtimeContext";
interface PhasePresentation {
label: string;
detail: string;
busy: boolean;
}
function phasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
): PhasePresentation {
const presentations: Record<AcquisitionState, PhasePresentation> = {
preparing: {
label: "Подготовка локального приёма",
detail: "Проверяем контур и создаём сессию записи.",
busy: true,
},
prepared: {
label: "Приём подготовлен",
detail: softwareCommanded
? "Можно инициировать работу устройства из Mission Core."
: "Программная команда K1 недоступна в текущем профиле.",
busy: false,
},
awaiting_external_start: {
label: "Ожидание запуска на устройстве",
detail: "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
? "Калибровка оборудования"
: "Подготовка локального приёмника",
detail: softwareCommanded
? "Статическая инициализация после запуска — не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
acquiring: {
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
detail: softwareCommanded
? "Состояние получено из профилированного контура управления K1."
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
busy: false,
},
awaiting_external_stop: {
label: "Ожидание остановки на устройстве",
detail: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
label: softwareCommanded ? "Останавливаем K1 и запись" : "Останавливаем локальный приём",
detail: softwareCommanded
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
: "Физическое состояние K1 остаётся неизвестным.",
busy: true,
},
finalizing: {
label: "Сохраняем локальную запись",
detail: "Не закрывайте Mission Core до завершения финализации.",
busy: true,
},
completed: { label: "Приём завершён", detail: "", busy: false },
failed: { label: "Ошибка приёма", detail: "", busy: false },
aborted: { label: "Приём прерван", detail: "", busy: false },
interrupted: { label: "Приём прерван", detail: "", busy: false },
};
return presentations[acquisition.state];
}
function formatDuration(seconds: number): string {
const wholeSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(wholeSeconds / 3_600);
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
const remainder = wholeSeconds % 60;
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
return null;
}
const softwareCommanded = isSoftwareCommandedAcquisition(state);
const phase = phasePresentation(acquisition, softwareCommanded);
const telemetry = deviceTelemetry(state.metrics);
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
acquisition.state,
);
const stopDisabled = pendingAction !== null || stopping;
const actionFailure = spatialActionFailure(
controller.error ??
(cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку."
: null),
);
return (
<section
className="xgrids-k1-spatial-controls"
aria-label="Управление сессией XGRIDS K1"
data-busy={phase.busy ? "true" : undefined}
>
<div className="xgrids-k1-spatial-controls__phase">
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : null}
<span>
<strong>{phase.label}</strong>
<small>{phase.detail}</small>
</span>
</div>
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
{telemetry.elapsedSeconds !== null ? (
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
) : null}
{telemetry.routeDistanceMeters !== null ? (
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
) : null}
{telemetry.speedMetersPerSecond !== null ? (
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
) : null}
</div>
{actionFailure ? (
<div className="xgrids-k1-spatial-controls__error" role="alert">
<strong>{actionFailure.title}</strong>
<small>{actionFailure.detail}</small>
</div>
) : null}
<Button
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop()}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
: stopping
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
: actionFailure
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
</section>
);
}
@@ -29,6 +29,17 @@ export function isTerminalAcquisitionState(
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
}
export function shouldRenderSpatialControls(
state: XgridsK1State | null | undefined,
): boolean {
const acquisition = state?.acquisition;
if (!acquisition || state?.source_mode === "replay") return false;
return (
!isTerminalAcquisitionState(acquisition.state) ||
acquisition.cleanup_pending === true
);
}
export function recoverableAcquisition(
state: XgridsK1State | null | undefined,
): XgridsAcquisition | null {
@@ -44,6 +55,21 @@ export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): bo
return state?.source_mode === "live" || state?.source_mode === "replay";
}
export function isVendorWriteCapable(
state: XgridsK1State | null | undefined,
): boolean {
return (
state?.compatibility?.vendor_writes_enabled === true &&
state.compatibility.permitted_mode === "active-control"
);
}
export function isSoftwareCommandedAcquisition(
state: XgridsK1State | null | undefined,
): boolean {
return isVendorWriteCapable(state) && state?.acquisition?.control_mode === "plugin-commanded";
}
export function confirmedRuntimeSourceMode(
state: XgridsK1State | null | undefined,
): RuntimeSourceMode {
+2
View File
@@ -1,5 +1,6 @@
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
import { XgridsK1Connection } from "./XgridsK1Connection";
import { K1SpatialControls } from "./components/K1SpatialControls";
import { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext";
import "./styles.css";
@@ -7,6 +8,7 @@ import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,
RuntimeProvider: XgridsK1RuntimeProvider,
SpatialControlsView: K1SpatialControls,
connectionViews: Object.freeze({
"xgrids-k1.connection": XgridsK1Connection,
}),
@@ -69,6 +69,52 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number |
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
function nonNegativeMetric(value: number | null | undefined): number | null {
const finite = finiteMetric(value);
return finite !== null && finite >= 0 ? finite : null;
}
export interface K1DeviceTelemetry {
elapsedSeconds: number | null;
routeDistanceMeters: number | null;
speedMetersPerSecond: number | null;
}
export interface SpatialActionFailure {
title: string;
detail: string;
}
export function spatialActionFailure(
error: string | null | undefined,
): SpatialActionFailure | null {
const detail = error?.trim();
return detail
? {
title: "Действие K1 не выполнено",
detail,
}
: null;
}
export function deviceTelemetry(
metrics: XgridsK1Metrics | null | undefined,
): K1DeviceTelemetry {
return {
elapsedSeconds: nonNegativeMetric(
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
),
routeDistanceMeters: nonNegativeMetric(
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
),
speedMetersPerSecond: nonNegativeMetric(
metrics?.device_speed_meters_per_second ??
metrics?.device_speed_mps ??
metrics?.speed_meters_per_second,
),
};
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
@@ -0,0 +1,32 @@
export const K1_PROJECT_NAME_MAX_LENGTH = 96;
export interface ProjectNameValidation {
value: string;
error: string | null;
}
const CONTROL_CHARACTER_OR_SURROGATE = /[\p{Cc}\p{Cs}]/u;
export function normalizeProjectName(input: string): string {
return input.normalize("NFKC").trim();
}
export function validateProjectName(input: string): ProjectNameValidation {
const value = normalizeProjectName(input);
if (!value) {
return { value, error: "Введите название проекта." };
}
if (CONTROL_CHARACTER_OR_SURROGATE.test(value)) {
return {
value,
error: "Название проекта не должно содержать управляющие символы.",
};
}
if (Array.from(value).length > K1_PROJECT_NAME_MAX_LENGTH) {
return {
value,
error: `Название проекта должно быть не длиннее ${K1_PROJECT_NAME_MAX_LENGTH} символов.`,
};
}
return { value, error: null };
}
@@ -15,7 +15,7 @@ import {
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
import { xgridsK1ObservationSources } from "./observationSources";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
@@ -30,6 +30,7 @@ function normalizeState(
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const telemetry = deviceTelemetry(metrics);
const deviceRef = state.device_ref;
const deviceSession = state.device_session;
const acquisition = effectiveAcquisition(state);
@@ -69,6 +70,7 @@ function normalizeState(
state: acquisition.state,
stateRevision: acquisition.state_revision,
operatorInstructions: acquisition.operator_instructions ?? [],
cleanupPending: acquisition.cleanup_pending === true,
}
: null,
operations: (state.operations ?? []).map((operation) => ({
@@ -106,6 +108,9 @@ function normalizeState(
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
pointCount: finiteMetric(metrics?.point_count),
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
elapsedSeconds: telemetry.elapsedSeconds,
routeDistanceMeters: telemetry.routeDistanceMeters,
speedMetersPerSecond: telemetry.speedMetersPerSecond,
},
};
}
+112
View File
@@ -464,3 +464,115 @@
flex-direction: column;
}
}
.xgrids-k1-spatial-controls {
display: flex;
min-width: min(42rem, 100%);
max-width: 100%;
align-items: center;
gap: 0.85rem;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 1rem;
background: rgb(9 10 13 / 0.88);
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
color: var(--nodedc-text-primary);
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
backdrop-filter: blur(20px);
}
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
flex: 1 1 15rem;
align-items: center;
gap: 0.58rem;
}
.xgrids-k1-spatial-controls__phase > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.xgrids-k1-spatial-controls__phase strong,
.xgrids-k1-spatial-controls__phase small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__phase strong {
font-size: 0.66rem;
}
.xgrids-k1-spatial-controls__phase small {
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.xgrids-k1-spatial-controls__spinner {
width: 0.82rem;
height: 0.82rem;
flex: 0 0 0.82rem;
border: 1px solid rgb(255 255 255 / 0.16);
border-top-color: var(--nodedc-text-primary);
border-radius: 50%;
animation: xgrids-k1-spin 900ms linear infinite;
}
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 0.65rem;
}
.xgrids-k1-spatial-controls__telemetry > span {
display: grid;
gap: 0.12rem;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__telemetry small {
color: var(--nodedc-text-muted);
font-size: 0.48rem;
}
.xgrids-k1-spatial-controls__telemetry strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error {
display: grid;
max-width: 17rem;
gap: 0.12rem;
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-spatial-controls__error strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error small {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.51rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes xgrids-k1-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 960px) {
.xgrids-k1-spatial-controls {
min-width: 0;
}
.xgrids-k1-spatial-controls__phase small,
.xgrids-k1-spatial-controls__telemetry,
.xgrids-k1-spatial-controls__error small {
display: none;
}
}
@@ -17,6 +17,7 @@ import {
liveStartPlan,
operationByIdempotencyKey,
operationNeedsReconciliation,
isSoftwareCommandedAcquisition,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering";
@@ -65,6 +66,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const actionInFlight = useRef(false);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
@@ -101,6 +103,8 @@ 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;
setPendingAction(action);
setError(null);
@@ -117,6 +121,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
}
return false;
} finally {
actionInFlight.current = false;
if (mounted.current) setPendingAction(null);
}
},
@@ -202,13 +207,13 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (acquisition && !acquisitionTerminal) {
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "capture-only",
mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state?.acquisition],
[run, state],
);
const abort = useCallback(() => {
+2
View File
@@ -60,6 +60,8 @@
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
{ "id": "spatial.pose.live", "label": "Траектория" },
{ "id": "device.modeling.live", "label": "Метрики маршрута" },
{ "id": "device.status.live", "label": "Состояние сканирования" },
{ "id": "camera.preview.live", "label": "Видеокамеры" },
{ "id": "evidence.raw-capture", "label": "Исходная запись" },
{ "id": "evidence.replay", "label": "Повтор записи" }
+109 -18
View File
@@ -62,12 +62,15 @@ def _validate_evidence(value: Any, path: str) -> dict[str, bool]:
if set(evidence) != set(EVIDENCE_FLAGS):
expected = ", ".join(EVIDENCE_FLAGS)
raise CompatibilityProfileError(f"{path} must contain exactly: {expected}")
validated: dict[str, bool] = {}
for flag in EVIDENCE_FLAGS:
if not isinstance(evidence[flag], bool):
flag_value = evidence[flag]
if not isinstance(flag_value, bool):
raise CompatibilityProfileError(f"{path}.{flag} must be a boolean")
validated[flag] = flag_value
if evidence["write_enabled"]:
raise CompatibilityProfileError(f"{path}.write_enabled must remain false in v1")
return evidence # type: ignore[return-value]
return validated
def _walk_and_validate_evidence(value: Any, path: str = "$") -> None:
@@ -240,6 +243,7 @@ def _validate_channels(profile: dict[str, Any]) -> None:
expected_ids = {
"spatial.point-cloud.live",
"spatial.pose.live",
"device.modeling.live",
"device.status.live",
"device.heartbeat.live",
"camera.preview.live",
@@ -271,21 +275,66 @@ def _validate_channels(profile: dict[str, Any]) -> None:
physical_verified=True,
)
for channel_id, topic in (
("device.status.live", "lixel/application/report/device_status"),
("device.heartbeat.live", "lixel/application/report/heartbeat"),
):
channel = channels[channel_id]
if channel.get("topic") != topic or channel.get("semantic_payload") is not None:
raise CompatibilityProfileError(f"{channel_id} must remain raw-only in v1")
_expect_evidence(
channel,
f"$.channels[{channel_id}]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
modeling = channels["device.modeling.live"]
if (
modeling.get("topic") != "lixel/application/report/modeling"
or modeling.get("wire_format") != "protobuf ModelingReport acquisition telemetry subset"
or modeling.get("semantic_payload")
!= (
"nonnegative MoveDistance metres, MoveSpeed metres per second, "
"int64 ScanTime at two ticks per second, and int32 PgoProgress"
)
or modeling.get("bounds")
!= {
"max_mqtt_payload_bytes": 65_536,
"max_fields": 64,
"max_nested_fields": 16,
}
):
raise CompatibilityProfileError("modeling telemetry channel differs from evidence")
_expect_evidence(
modeling,
"$.channels[device.modeling.live]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
status = channels["device.status.live"]
if (
status.get("topic") != "lixel/application/report/device_status"
or status.get("wire_format") != "protobuf DeviceStatusReport acquisition lifecycle subset"
or status.get("semantic_payload")
!= (
"bounded modeling-state base-offset mapping, init-ready flag, "
"project presence and redacted identity fields"
)
):
raise CompatibilityProfileError("device-status channel differs from evidence")
_expect_evidence(
status,
"$.channels[device.status.live]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
heartbeat = channels["device.heartbeat.live"]
if (
heartbeat.get("topic") != "lixel/application/report/heartbeat"
or heartbeat.get("semantic_payload") is not None
):
raise CompatibilityProfileError("device.heartbeat.live must remain raw-only in v1")
_expect_evidence(
heartbeat,
"$.channels[device.heartbeat.live]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
camera = channels["camera.preview.live"]
if camera.get("discovery_status") != "observed":
@@ -353,14 +402,53 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
)
if mapping.get("evidence_kind") != "owner-controlled-wire-observation":
raise CompatibilityProfileError(f"{action_id} vendor mapping differs from evidence")
if mapping.get("transport") != "MQTT 3.1.1":
raise CompatibilityProfileError(f"{action_id} transport differs from evidence")
if mapping.get("message_type") != "ModelingRequest":
raise CompatibilityProfileError(f"{action_id} message type differs from evidence")
if mapping.get("write_enabled") is not False:
raise CompatibilityProfileError(
f"{action_id} vendor mapping must explicitly remain write-disabled"
)
if mapping.get("topic") != "lixel/application/request/modeling":
raise CompatibilityProfileError(
f"{action_id} vendor topic differs from static evidence"
)
if mapping.get("qos") != 2 or mapping.get("action_field_value") != action_value:
if (
mapping.get("qos") != 2
or mapping.get("retain") is not False
or mapping.get("action_field_value") != action_value
):
raise CompatibilityProfileError(
f"{action_id} vendor mapping differs from static evidence"
)
if mapping.get("header_contract") != {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance",
}:
raise CompatibilityProfileError(
f"{action_id} header contract differs from retained evidence"
)
expected_request_fields: dict[str, object] = (
{
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request",
}
if action_id == "acquisition.start"
else {}
)
if mapping.get("request_fields") != expected_request_fields:
raise CompatibilityProfileError(
f"{action_id} request fields differ from retained evidence"
)
if mapping.get("success_result_code") != 302_252_033:
raise CompatibilityProfileError(
f"{action_id} success result differs from retained evidence"
)
if not _array(
mapping.get("required_unresolved_context"),
f"$.acquisition_control.semantic_actions[{action_id}].required_unresolved_context",
@@ -407,6 +495,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
raise CompatibilityProfileError("unexpected compatibility profile_id")
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")
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")
@@ -470,7 +560,8 @@ def matches_target(
"""Return whether an already validated exact-match profile covers the target."""
validated = validate_compatibility_profile(profile)
scope = validated["scope"]
return scope["firmware"]["version"] == firmware and scope["topology"] == topology
firmware_scope = _object(scope.get("firmware"), "$.scope.firmware")
return firmware_scope.get("version") == firmware and scope.get("topology") == topology
def _main() -> int:
+14 -7
View File
@@ -29,15 +29,22 @@ boolean flags:
| `physical_verified` | Correlated with a controlled physical state/action |
| `write_enabled` | The profile grants emission of a state-changing request |
`decoded` does not mean that MQTT framing alone was parsed. Status and heartbeat
are therefore observed raw channels, not decoded status models. Camera preview
transport is observed, but its media decoder/replay flags remain independent.
`decoded` does not mean that MQTT framing alone was parsed. Modeling telemetry
and the acquisition subset of DeviceStatus have bounded profile-scoped
decoders; heartbeat remains an observed raw channel. Camera preview transport
is observed, but its media decoder/replay flags remain independent.
The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
capture verifies the `ModelingRequest` topic and action values, but complete
device/session/OpenAPI header construction, settings, save completion, timeout
and rollback contracts remain unresolved. Acquisition therefore stays
`operator-manual` through the verified physical double-click.
capture verifies the `ModelingRequest` topic, action values, field layout,
literal `{device_id}:ModelingRequest` session relation, retained start settings
and numeric success code. The bounded codec accepts an OpenAPI value only as an
explicit secret; its provenance and secure provisioning are unresolved, as are
durable save completion, timeout and rollback behavior. Acquisition therefore
stays `operator-manual` through the verified physical double-click.
The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future
publisher must enforce the exact profile mapping (`2/1/0`, omitted
`pre_project_id`) in a separate reviewed gate.
The existing BLE Wi-Fi provisioning workflow has its own reviewed profile and
operator confirmation. Merely loading this compatibility profile neither calls
@@ -209,20 +209,49 @@
"live-viewer-profile"
]
},
{
"id": "device.modeling.live",
"kind": "acquisition-telemetry",
"direction": "device-report",
"topic": "lixel/application/report/modeling",
"wire_format": "protobuf ModelingReport acquisition telemetry subset",
"semantic_payload": "nonnegative MoveDistance metres, MoveSpeed metres per second, int64 ScanTime at two ticks per second, and int32 PgoProgress",
"bounds": {
"max_mqtt_payload_bytes": 65536,
"max_fields": 64,
"max_nested_fields": 16
},
"limitations": [
"ScanTime scale is exact-profile evidence and must not be generalized to other firmware.",
"Replay-to-view acceptance remains open even though retained physical runs establish units and live decoding."
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"live-viewer-profile"
]
},
{
"id": "device.status.live",
"kind": "device-status",
"direction": "device-report",
"topic": "lixel/application/report/device_status",
"wire_format": "opaque bytes",
"semantic_payload": null,
"wire_format": "protobuf DeviceStatusReport acquisition lifecycle subset",
"semantic_payload": "bounded modeling-state base-offset mapping, init-ready flag, project presence and redacted identity fields",
"limitations": [
"The report was physically observed, but no bounded semantic device-status decoder is implemented.",
"Raw capture is evidence; field names or meanings must not be inferred from payload shape."
"Nested system and RTK status payloads are presence-checked but not semantically decoded.",
"ScanOver and Ready observations do not prove durable artifact save completion."
],
"evidence": {
"observed": true,
"decoded": false,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
@@ -270,7 +299,7 @@
"limitations": [
"No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.",
"Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.",
"A bounded Mission Core camera decoder and replay fixture are not yet implemented."
"Mission Core implements bounded copy-remux, archival and replay admission, but no newly archived physical K1 camera session has passed shared-timeline playback acceptance."
],
"evidence": {
"observed": true,
@@ -313,11 +342,25 @@
"transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling",
"qos": 2,
"retain": false,
"message_type": "ModelingRequest",
"action_field_value": 1,
"header_contract": {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
},
"request_fields": {
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request"
},
"success_result_code": 302252033,
"required_unresolved_context": [
"complete device/session/OpenAPI header construction",
"project/record/scan/mount setting semantics and safe defaults",
"OpenAPI credential provenance and secure provisioning",
"authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract"
],
"evidence": {
@@ -352,10 +395,18 @@
"transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling",
"qos": 2,
"retain": false,
"message_type": "ModelingRequest",
"action_field_value": 2,
"header_contract": {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
},
"request_fields": {},
"success_result_code": 302252033,
"required_unresolved_context": [
"complete device/session/OpenAPI header construction",
"OpenAPI credential provenance and secure provisioning",
"save-completion and final-standby state mapping",
"timeout and rollback contract"
],