feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
|
||||
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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": "Повтор записи" }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user