feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user