Диагностика допуска правой камеры в интерфейс

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 12:21:23 +03:00
parent 456141a3f3
commit 4e00e804f3
5 changed files with 161 additions and 0 deletions
@@ -5,6 +5,8 @@ export type LiveViewerDiagnosticEventCode =
| "live_receiver_recovery_exhausted"
| "live_receiver_active_store_admitted"
| "live_receiver_error"
| "live_camera_source_projected"
| "live_camera_window_admitted"
| "live_camera_transport_restart_requested"
| "live_camera_transport_playing";
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import {
createLiveViewerInstanceId,
createLiveViewerLineage,
postLiveViewerDiagnostic,
type LiveViewerDiagnostic,
type LiveViewerLineage,
} from "./liveViewerDiagnostics";
import {
closeObservationSource,
openObservationSource,
@@ -88,6 +95,51 @@ export function automaticLivePresentationIdentity(
return JSON.stringify([source.id, acquisitionId, deliveryId]);
}
export interface LiveCameraPresentationDiagnostic {
key: string;
event: LiveViewerDiagnostic;
}
/**
* Read-only boundary markers for the automatic camera path. They distinguish
* a catalog/delivery failure from layout admission and player mounting without
* changing presentation state or contacting the device.
*/
export function pendingLiveCameraPresentationDiagnostics(
sources: readonly ObservationSourceDescriptor[],
visibleSourceIds: ReadonlySet<string>,
emittedKeys: ReadonlySet<string>,
): LiveCameraPresentationDiagnostic[] {
const pending: LiveCameraPresentationDiagnostic[] = [];
for (const source of sources) {
if (source.modality !== "video") continue;
const presentationIdentity = automaticLivePresentationIdentity(source);
const deliveryId = source.delivery?.id?.trim();
if (!presentationIdentity || !deliveryId) continue;
const projectedKey = `projected:${presentationIdentity}`;
if (!emittedKeys.has(projectedKey)) {
pending.push({
key: projectedKey,
event: {
eventCode: "live_camera_source_projected",
streamId: deliveryId,
},
});
}
const admittedKey = `admitted:${presentationIdentity}`;
if (visibleSourceIds.has(source.id) && !emittedKeys.has(admittedKey)) {
pending.push({
key: admittedKey,
event: {
eventCode: "live_camera_window_admitted",
streamId: deliveryId,
},
});
}
}
return pending;
}
/** A deliberate close fences every delivery generation in that acquisition. */
export function livePresentationCloseFence(
source: ObservationSourceDescriptor,
@@ -216,6 +268,8 @@ export function useObservationLayout(
const admittedLivePresentationIdentitiesRef = useRef(new Set<string>());
const closedLiveAcquisitionSourcesRef = useRef(new Set<string>());
const presentedLiveAcquisitionSourcesRef = useRef(new Set<string>());
const emittedLiveCameraDiagnosticKeysRef = useRef(new Set<string>());
const liveCameraDiagnosticLineageRef = useRef<LiveViewerLineage | null>(null);
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
@@ -432,6 +486,23 @@ export function useObservationLayout(
admitAutomaticLivePresentations();
}, [admitAutomaticLivePresentations, selectedDeliveryIdentity]);
useEffect(() => {
const diagnostics = pendingLiveCameraPresentationDiagnostics(
sources,
new Set(visibleIds),
emittedLiveCameraDiagnosticKeysRef.current,
);
if (!diagnostics.length) return;
liveCameraDiagnosticLineageRef.current ??= createLiveViewerLineage(
createLiveViewerInstanceId(),
1,
);
for (const diagnostic of diagnostics) {
emittedLiveCameraDiagnosticKeysRef.current.add(diagnostic.key);
postLiveViewerDiagnostic(diagnostic.event, liveCameraDiagnosticLineageRef.current);
}
}, [selectedDeliveryIdentity, sources, visibleIds]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
const affected = groupId
@@ -15,6 +15,7 @@ let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let admitLiveDefaultPresentations;
let automaticLivePresentationIdentity;
let pendingLiveCameraPresentationDiagnostics;
let livePresentationCloseFence;
let restoredLayoutMayAdmitLiveDefault;
let observationPresentationSourceAfterLayoutApply;
@@ -39,6 +40,7 @@ before(async () => {
({
admitLiveDefaultPresentations,
automaticLivePresentationIdentity,
pendingLiveCameraPresentationDiagnostics,
livePresentationCloseFence,
restoredLayoutMayAdmitLiveDefault,
observationPresentationSourceAfterLayoutApply,
@@ -375,6 +377,68 @@ test("a restored layout admits same-lineage replacements and the next acquisitio
assert.deepEqual(deliberatelyClosed.admittedIdentities, []);
});
test("live camera diagnostics distinguish catalog projection from window admission", () => {
const camera = {
id: "k1:sensor.camera.right",
sourceId: "sensor.camera.right",
modality: "video",
availability: "streaming",
transport: "websocket",
previewUrl: null,
delivery: {
id: "camera-preview-2",
kind: "mse-fmp4-websocket",
url: "/camera-preview/2",
mediaType: 'video/mp4; codecs="avc1.641028"',
},
activation: {
groupId: "k1:device-session:camera.preview.decoder",
maxActive: 1,
selected: true,
controllable: false,
},
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session",
acquisitionId: "acquisition-quick",
},
capabilities: { defaultVisible: true, overlay: true },
};
const projected = pendingLiveCameraPresentationDiagnostics(
[camera],
new Set(),
new Set(),
);
assert.deepEqual(projected.map(({ event }) => event), [{
eventCode: "live_camera_source_projected",
streamId: "camera-preview-2",
}]);
const emitted = new Set(projected.map(({ key }) => key));
const admitted = pendingLiveCameraPresentationDiagnostics(
[camera],
new Set([camera.id]),
emitted,
);
assert.deepEqual(admitted.map(({ event }) => event), [{
eventCode: "live_camera_window_admitted",
streamId: "camera-preview-2",
}]);
admitted.forEach(({ key }) => emitted.add(key));
assert.deepEqual(
pendingLiveCameraPresentationDiagnostics([camera], new Set([camera.id]), emitted),
[],
);
assert.deepEqual(
pendingLiveCameraPresentationDiagnostics(
[{ ...camera, delivery: null }],
new Set([camera.id]),
new Set(),
),
[],
);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
+2
View File
@@ -18,6 +18,8 @@ LiveViewerEventCode = Literal[
"live_receiver_recovery_exhausted",
"live_receiver_active_store_admitted",
"live_receiver_error",
"live_camera_source_projected",
"live_camera_window_admitted",
"live_camera_transport_restart_requested",
"live_camera_transport_playing",
]
+22
View File
@@ -244,6 +244,28 @@ def test_live_viewer_diagnostic_endpoint_accepts_only_bounded_events(
assert caplog.records[-1].camera_media_source_state == "open"
assert caplog.records[-1].camera_video_error_code == 3
for event_code in (
"live_camera_source_projected",
"live_camera_window_admitted",
):
boundary_event = LiveViewerDiagnosticEvent(
schema_version="missioncore.live-viewer-diagnostic/v2",
event_code=event_code,
ui_build_id=expected_build,
document_instance_id="00000000-0000-4000-8000-000000000001",
viewer_instance_id="00000000-0000-4000-8000-000000000004",
lifecycle_generation=1,
stream_id="camera-preview-2",
)
with caplog.at_level(
logging.INFO,
logger="k1link.device_plugins.xgrids_k1.viewer_receiver",
):
boundary_response = endpoint(boundary_event)
assert boundary_response.status_code == 204
assert caplog.records[-1].event_code == event_code
assert caplog.records[-1].stream_id == "camera-preview-2"
def test_live_viewer_diagnostic_rejects_stale_build_before_logging(
caplog: pytest.LogCaptureFixture,