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
+40 -18
View File
@@ -18,15 +18,15 @@ device adapter, но структура интерфейса от него не
| --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. |
| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
@@ -38,9 +38,10 @@ device adapter, но структура интерфейса от него не
```text
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
── bounded latest-wins preview queue (32 сообщения)
└── явно внедрённый K1 protobuf/LZ4 normalizer
└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
── injected ModelingReport observer -> device scan time/distance/speed
└── bounded latest-wins visual preview queue (4 сообщения)
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
└── transport-neutral DecodedPointCloudView / DecodedPoseView
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
@@ -55,7 +56,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
└── atomic RRD cache v7 + recorded-media manifest v2
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
@@ -177,15 +178,21 @@ production-сборки запускается командой `npm run preview
оператор.
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
7. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства.
8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
проверяется на control/surrogate characters и лимит 96 Unicode characters,
сохраняется как display metadata и не используется в filesystem path.
8. Запустить локальный live-приём по определённому адресу K1 либо replay
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
и останавливается подтверждённым двойным нажатием кнопки устройства.
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
показывает local acquisition phase, stop local reception и полученные от K1
scan time/distance/speed; облако, траектория и preview metrics появляются
только после реальных сообщений.
11. Физически остановить K1, дождаться steady green, затем остановить локальный
приём из device workflow или spatial block, чтобы запечатать evidence.
12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
сохраняется автоматически; disk action сохраняет только workspace layout.
@@ -212,6 +219,11 @@ https://example.internal/recording.rrd
- версия RRD должна быть совместима с версией Web Viewer;
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
- nonterminal acquisition fail-closed блокирует manual source apply/reset,
saved session switch и persisted replay reattach до завершения текущего
приёма; automatic-source action очищает старый source и открывает сцену только
после successful start, failed start сохраняет текущую сцену, а guard не
останавливает устройство;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
@@ -282,10 +294,10 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
@@ -312,16 +324,26 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
- Exact start/stop codec, response correlator и device-status state machine
остаются inert: они не импортируют MQTT transport. Header derivation известен,
но legitimate OpenAPI key provisioning и durable post-stop save gate не
доказаны, поэтому `vendor_writes_enabled=false`.
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
pointer с provisional `mqtt.timeline.json` на content-addressed
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
может рекламировать combined media replay. Cache v9 создаёт реальные
origin/end RRD rows; provisional `transport` scope fails closed.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
Физический archived playback point cloud + одна выбранная камера ещё не принят.
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
могут содержать адреса, изображения, идентификаторы, траекторию и карту
помещения. В репозиторий попадают только код, тесты, redacted manifests и
+45 -3
View File
@@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import {
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
isSpatialSourceSwitchBlocked,
} from "./core/runtime/acquisitionGuard";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
@@ -144,6 +148,12 @@ export default function App() {
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
const sourceSwitchBlockedReason = sourceSwitchBlocked
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
: null;
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
@@ -423,6 +433,9 @@ export default function App() {
};
const beginRecordedReplaySwitch = useCallback(async () => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
// useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
@@ -437,6 +450,9 @@ export default function App() {
}, []);
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
setRecordedReplay(launch);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
@@ -447,6 +463,17 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard.
setReplayTransitioning(false);
setRecordedReplay(null);
setSourceUrl("");
setSourceDraft("");
}, []);
useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely
@@ -637,7 +664,8 @@ export default function App() {
<div className="observation-header-tools">
<ObservationSessionSelect
limit={3}
disabled={runtime.pendingAction !== null}
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
@@ -667,6 +695,7 @@ export default function App() {
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : (
<WorkspaceRenderer
@@ -682,11 +711,18 @@ export default function App() {
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
observationLayout={observationLayout}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,
model: selection.model,
}
: null}
navigation={{
openView,
openSource,
openDisplay,
openLayers,
activateAutomaticSpatialSource,
}}
/>
)}
@@ -713,7 +749,10 @@ export default function App() {
<WindowFooterActions>
<Button
variant="ghost"
disabled={sourceSwitchBlocked}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
@@ -724,8 +763,10 @@ export default function App() {
<Button
variant="primary"
shape="pill"
disabled={!sourceDraft.trim()}
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
}}
@@ -766,9 +807,10 @@ export default function App() {
hint="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
disabled={sourceSwitchBlocked}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
/>
</div>
),
@@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string {
export function ObservationSessionSelect({
limit = 3,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
@@ -114,6 +116,7 @@ export function ObservationSessionSelect({
}) {
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
@@ -123,6 +126,7 @@ export function ObservationSessionSelect({
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return (
<Dropdown
@@ -147,10 +151,11 @@ export function ObservationSessionSelect({
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{triggerCopy}</span>
<span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
@@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2(
export interface DevicePluginHostActions {
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface DevicePluginConnectionProps {
@@ -109,10 +110,12 @@ export interface DeviceUiPlugin {
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
@@ -90,7 +90,14 @@ export function createDevicePluginRegistry(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({ plugin, model, ConnectionView });
models.push({
plugin,
model,
ConnectionView,
...(plugin.SpatialControlsView
? { SpatialControlsView: plugin.SpatialControlsView }
: {}),
});
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
@@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat
};
},
cancel() {
sequence += 1;
active?.abort();
active = null;
// Keep ownership until the cancelled attempt reaches `finish()`. This
// lets its finally block settle a replacement that already passed
// onReplayBegin, while `isCurrent()` still fails immediately because the
// signal is aborted. A later `begin()` replaces and invalidates it.
},
};
}
@@ -359,11 +361,13 @@ export function clearObservationReplayPreparation(
export function useObservationSessions({
limit = 3,
replayEnabled = true,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
replayEnabled?: boolean;
/** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: (
session: ObservationSessionSummary,
@@ -388,11 +392,23 @@ export function useObservationSessions({
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const replayEnabledRef = useRef(replayEnabled);
replayEnabledRef.current = replayEnabled;
const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator();
}
useEffect(() => {
if (replayEnabled) return;
// Cancels only this browser's polling/selection attempt. The shared
// backend preparation remains untouched and can be selected again later.
reattachStarted.current = true;
replayCoordinator.current?.cancel();
setReplayingSessionId(null);
setReplayProgress(null);
}, [replayEnabled]);
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const refresh = useCallback(async () => {
@@ -466,6 +482,7 @@ export function useObservationSessions({
session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation,
) => {
if (!replayEnabledRef.current) return false;
const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id);
setFailedSessionId(null);
@@ -502,14 +519,26 @@ export function useObservationSessions({
signal: attempt.signal,
onUpdate,
});
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
// The current scene stays mounted throughout preparation. Only now that
// the launch descriptor exists do we release the previous viewer.
await onReplayBegin?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
await onReplayAccepted?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
outcome = "accepted";
try {
clearObservationReplayPreparation();
@@ -543,6 +572,7 @@ export function useObservationSessions({
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
const replay = useCallback(async (sessionId: string) => {
if (!replayEnabledRef.current) return false;
const session = items.find((candidate) => candidate.id === sessionId);
if (!session || !session.replayable) return false;
reattachStarted.current = true;
@@ -550,7 +580,7 @@ export function useObservationSessions({
}, [executeReplay, items]);
useEffect(() => {
if (state !== "ready" || reattachStarted.current) return;
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null;
try {
@@ -569,7 +599,7 @@ export function useObservationSessions({
return;
}
void executeReplay(session, stored);
}, [executeReplay, items, state]);
}, [executeReplay, items, replayEnabled, state]);
const retry = useCallback(async () => {
if (!failedSessionId) return false;
@@ -0,0 +1,26 @@
import type { MissionRuntimeState } from "./contracts";
const TERMINAL_ACQUISITION_STATES = new Set([
"completed",
"failed",
"aborted",
"interrupted",
]);
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
"Завершите текущий приём перед сменой источника.";
/**
* An acquisition snapshot is blocking unless its state is explicitly known to
* be terminal. Unknown future states therefore fail closed.
*/
export function isSpatialSourceSwitchBlocked(
state: MissionRuntimeState | null | undefined,
): boolean {
const acquisition = state?.acquisition;
return Boolean(
acquisition &&
(acquisition.cleanupPending === true ||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
);
}
@@ -29,6 +29,9 @@ export interface StreamMetrics {
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
elapsedSeconds?: number | null;
routeDistanceMeters?: number | null;
speedMetersPerSecond?: number | null;
}
export interface ActiveDeviceSnapshot {
@@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot {
state: string;
stateRevision: number;
operatorInstructions: readonly string[];
cleanupPending?: boolean;
}
export interface RuntimeOperationSnapshot {
@@ -8,6 +8,15 @@
gap: 0.45rem;
}
.scene-device-controls {
position: absolute;
z-index: 30;
top: 0.85rem;
left: 50%;
max-width: calc(100% - 9rem);
transform: translateX(-50%);
}
.scene-source-picker__trigger,
.scene-source-control,
.scene-focus-exit {
@@ -168,6 +168,11 @@
left: 0.6rem;
}
.scene-device-controls {
top: 0.6rem;
max-width: calc(100% - 8rem);
}
.scene-adapter-note {
right: 0.75rem;
left: 0.75rem;
@@ -40,7 +40,13 @@ function ModelCard({
);
}
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
onOpenSpatialScene: () => void;
onActivateAutomaticSpatialSource: () => void;
}) {
const {
registry,
selection,
@@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: ()
</div>
<ConnectionView
model={selection.model}
host={{ openSpatialScene: onOpenSpatialScene }}
host={{
openSpatialScene: onOpenSpatialScene,
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
}}
/>
</div>
);
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import {
Button,
GlassSurface,
@@ -21,6 +28,10 @@ import type {
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
DeviceModelDefinition,
DevicePluginConnectionProps,
} from "../core/device-plugins/contracts";
import type {
BackendStatus,
MissionRuntimeState,
@@ -98,6 +109,7 @@ export interface WorkspaceNavigation {
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface WorkspaceRendererProps {
@@ -113,6 +125,10 @@ export interface WorkspaceRendererProps {
onAccumulationCommit: () => void;
observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
model: DeviceModelDefinition;
} | null;
}
function OverviewWorkspace({
@@ -271,6 +287,7 @@ function SpatialWorkspace({
onAccumulationCommit,
observationLayout,
navigation,
spatialControls,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
@@ -435,6 +452,18 @@ function SpatialWorkspace({
<EmptySpatialStage settings={sceneSettings} />
)}
{spatialControls && !recordedSource ? (
<div className="scene-device-controls">
<spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>
</div>
) : null}
{pointCloudFocused ? (
<button
type="button"
@@ -10,6 +10,9 @@ let xgridsK1Manifest;
let xgridsK1Actions;
let xgridsK1Api;
let lifecycle;
let projectName;
let automaticSourceStart;
let presentation;
before(async () => {
server = await createServer({
@@ -29,6 +32,15 @@ before(async () => {
lifecycle = await server.ssrLoadModule(
"@xgrids-k1/frontend/lifecycle.ts",
);
projectName = await server.ssrLoadModule(
"@xgrids-k1/frontend/projectName.ts",
);
automaticSourceStart = await server.ssrLoadModule(
"@xgrids-k1/frontend/automaticSourceStart.ts",
);
presentation = await server.ssrLoadModule(
"@xgrids-k1/frontend/presentation.ts",
);
({ xgridsK1Api } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
@@ -279,6 +291,100 @@ test("prepared acquisition resumes without another prepare and remains recoverab
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
});
test("project name is canonicalized and rejected outside the bounded safe contract", () => {
assert.deepEqual(projectName.validateProjectName(" Mission 01 "), {
value: "Mission 01",
error: null,
});
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
});
test("automatic spatial source replaces the old scene only after a successful start", async () => {
const failedEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
failedEvents.push("start");
return false;
},
() => failedEvents.push("activate"),
() => failedEvents.push("open"),
), false);
assert.deepEqual(failedEvents, ["start"]);
const successfulEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
successfulEvents.push("start");
return true;
},
() => successfulEvents.push("activate"),
() => successfulEvents.push("open"),
), true);
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
});
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
const capability = {
compatibility: {
vendor_writes_enabled: true,
permitted_mode: "active-control",
},
};
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
assert.equal(lifecycle.isVendorWriteCapable({
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "operator-manual" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "plugin-commanded" },
}), true);
});
test("device modeling telemetry maps only finite non-negative values", () => {
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: 12.5,
device_route_distance_meters: 8.25,
device_speed_meters_per_second: 0.75,
}), {
elapsedSeconds: 12.5,
routeDistanceMeters: 8.25,
speedMetersPerSecond: 0.75,
});
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: -1,
device_route_distance_meters: Number.NaN,
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
}), {
elapsedSeconds: null,
routeDistanceMeters: null,
speedMetersPerSecond: null,
});
});
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
assert.equal(presentation.spatialActionFailure(null), null);
assert.equal(presentation.spatialActionFailure(" "), null);
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
title: "Действие K1 не выполнено",
detail: "stop failed",
});
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "live",
acquisition: { state: "failed", cleanup_pending: true },
}), true);
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "idle",
acquisition: { state: "failed", cleanup_pending: false },
}), false);
});
test("replay ignores a stale failed live acquisition", () => {
const replay = {
source_mode: "replay",
@@ -343,6 +449,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
compatibility_attestation: attestation,
});
} finally {
@@ -355,4 +462,5 @@ test("device mutations send explicit nested compatibility attestation", async ()
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01");
});
@@ -39,7 +39,13 @@ after(async () => {
await server?.close();
});
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
function syntheticPlugin({
pluginId,
modelId,
componentKey,
connectionView,
spatialControlsView,
}) {
return {
manifest: {
apiVersion: "missioncore.nodedc/v1alpha2",
@@ -69,6 +75,7 @@ function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
},
RuntimeProvider: ({ children }) => children,
connectionViews: { [componentKey]: connectionView },
...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
};
}
@@ -98,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
);
});
test("registry exposes an optional model-scoped spatial controls contribution", () => {
const connectionView = () => null;
const spatialControlsView = () => null;
const registry = createDevicePluginRegistry([
syntheticPlugin({
pluginId: "synthetic.spatial",
modelId: "synthetic.spatial.sensor",
componentKey: "spatial.connection",
connectionView,
spatialControlsView,
}),
syntheticPlugin({
pluginId: "synthetic.connection-only",
modelId: "synthetic.connection-only.sensor",
componentKey: "connection-only.connection",
connectionView,
}),
]);
assert.equal(
registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
spatialControlsView,
);
assert.equal(
registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
undefined,
);
});
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
if (existsSync(legacyPluginRoot)) {
assert.deepEqual(readdirSync(legacyPluginRoot), []);
@@ -108,10 +144,22 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
"styles.css",
"components/K1ProvisioningPipeline.tsx",
"components/K1AcquisitionPipeline.tsx",
"components/K1SpatialControls.tsx",
"components/K1Diagnostics.tsx",
"projectName.ts",
]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
}
const spatialControls = readFileSync(
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
"utf8",
);
assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
assert.match(spatialControls, /cleanup_pending/);
assert.match(spatialControls, /spatialActionFailure/);
assert.match(spatialControls, /role="alert"/);
assert.match(spatialControls, /Повторить остановку/);
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
@@ -603,6 +603,25 @@ test("replay coordinator makes rapid saved-session selection latest-request-wins
coordinator.cancel();
assert.equal(third.signal.aborted, true);
assert.equal(third.isCurrent(), false);
assert.equal(third.finish(), true);
assert.equal(third.finish(), false);
});
test("an acquisition guard cancellation keeps settlement ownership", () => {
const coordinator = createObservationReplayCoordinator();
const archiveSwitch = coordinator.begin();
let replacementBlanked = false;
let replacementSettled = false;
// Models the point after onReplayBegin has released the previous viewer.
replacementBlanked = true;
coordinator.cancel();
assert.equal(archiveSwitch.signal.aborted, true);
assert.equal(archiveSwitch.isCurrent(), false);
if (archiveSwitch.finish()) replacementSettled = true;
assert.equal(replacementBlanked, true);
assert.equal(replacementSettled, true);
});
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let guard;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
});
after(async () => {
await server?.close();
});
function runtimeState(acquisitionState, cleanupPending = false) {
return {
phase: "idle",
sourceMode: "idle",
acquisition: acquisitionState === null
? null
: {
acquisitionId: "acq-1",
deviceId: "device-1",
deviceSessionId: "device-session-1",
compatibilityProfileId: "profile-1",
controlMode: "operator-manual",
state: acquisitionState,
stateRevision: 1,
operatorInstructions: [],
cleanupPending,
},
};
}
test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
for (const state of [
"preparing",
"prepared",
"awaiting_external_start",
"starting",
"acquiring",
"awaiting_external_stop",
"stopping",
"finalizing",
]) {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
}
});
test("terminal or absent acquisitions allow an explicit source selection", () => {
for (const state of ["completed", "failed", "aborted", "interrupted"]) {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
}
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
});
test("an unknown acquisition state fails closed", () => {
assert.equal(
guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
true,
);
assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
});
test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
});
test("saved-session and manual source controls share the acquisition guard", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const sessionSelectSource = await readFile(
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
"utf8",
);
const observationHookSource = await readFile(
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
"utf8",
);
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
assert.match(observationHookSource, /!replayEnabledRef\.current/);
});