From 4e00e804f3c98faeac387417fd69f131000c2ca0 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 23 Aug 2026 12:21:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=B9=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D1=8B=20=D0=B2=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/observation/liveViewerDiagnostics.ts | 2 + .../core/observation/useObservationLayout.ts | 71 +++++++++++++++++++ .../test/workspaceLayout.test.mjs | 64 +++++++++++++++++ src/k1link/web/viewer_diagnostics_api.py | 2 + tests/test_viewer_diagnostics_api.py | 22 ++++++ 5 files changed, 161 insertions(+) diff --git a/apps/control-station/src/core/observation/liveViewerDiagnostics.ts b/apps/control-station/src/core/observation/liveViewerDiagnostics.ts index b88062e..d582db0 100644 --- a/apps/control-station/src/core/observation/liveViewerDiagnostics.ts +++ b/apps/control-station/src/core/observation/liveViewerDiagnostics.ts @@ -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"; diff --git a/apps/control-station/src/core/observation/useObservationLayout.ts b/apps/control-station/src/core/observation/useObservationLayout.ts index 22c13ac..ee0d187 100644 --- a/apps/control-station/src/core/observation/useObservationLayout.ts +++ b/apps/control-station/src/core/observation/useObservationLayout.ts @@ -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, + emittedKeys: ReadonlySet, +): 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()); const closedLiveAcquisitionSourcesRef = useRef(new Set()); const presentedLiveAcquisitionSourcesRef = useRef(new Set()); + const emittedLiveCameraDiagnosticKeysRef = useRef(new Set()); + const liveCameraDiagnosticLineageRef = useRef(null); const initializedCatalog = useRef(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 diff --git a/apps/control-station/test/workspaceLayout.test.mjs b/apps/control-station/test/workspaceLayout.test.mjs index f5a7c20..99f0bee 100644 --- a/apps/control-station/test/workspaceLayout.test.mjs +++ b/apps/control-station/test/workspaceLayout.test.mjs @@ -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()); diff --git a/src/k1link/web/viewer_diagnostics_api.py b/src/k1link/web/viewer_diagnostics_api.py index 7b1ecd6..a29e516 100644 --- a/src/k1link/web/viewer_diagnostics_api.py +++ b/src/k1link/web/viewer_diagnostics_api.py @@ -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", ] diff --git a/tests/test_viewer_diagnostics_api.py b/tests/test_viewer_diagnostics_api.py index a73fd07..a13f2fa 100644 --- a/tests/test_viewer_diagnostics_api.py +++ b/tests/test_viewer_diagnostics_api.py @@ -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,