import type { ViewerSettings } from "@mission-core/plugin-sdk"; import { xgridsK1Actions, xgridsK1Manifest } from "./manifest"; const PLUGIN_ID = xgridsK1Manifest.metadata.id; export interface BleDevice { device_id: string; name?: string | null; rssi?: number | null; address?: string | null; connectable?: boolean | null; likely_k1?: boolean | null; } export type SourceMode = "idle" | "live" | "replay"; export type AcquisitionState = | "preparing" | "prepared" | "awaiting_external_start" | "starting" | "acquiring" | "awaiting_external_stop" | "stopping" | "finalizing" | "completed" | "failed" | "aborted" | "interrupted"; export type OperationStatus = | "accepted" | "running" | "operator_action_required" | "succeeded" | "failed" | "cancelled" | "timed_out" | "interrupted"; export interface XgridsDeviceRef { device_id: string; model_id: string; identity_stability: "stable" | "provisional"; identity_basis: | "hardware-identifier" | "plugin-derived" | "operator-assigned" | "transport-local"; transport_alias?: string | null; } export interface XgridsDeviceSession { device_session_id: string; device_id: string; opened_at?: string | null; compatibility_profile_id?: string | null; connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded"; } export interface XgridsCompatibilityState { profile_id?: string | null; decision?: "compatible" | "limited" | "unknown" | "incompatible"; permitted_mode?: "blocked" | "evidence-only" | "read-only" | "active-control"; firmware_claim?: string | null; vendor_writes_enabled?: boolean; camera_preview?: string | null; attestation?: CompatibilityAttestation | null; } export interface XgridsModelingControlSafety { mode: "shadow-only"; status_reports_observed: number; decode_errors: number; vendor_identity_observed: boolean; device_serial_observed: boolean; identity_conflict: boolean; session_state?: string | null; project_bound: boolean; ready_for_start_shadow: boolean; ready_for_stop_shadow: boolean; vendor_writes_enabled: false; publisher_installed: false; } export interface XgridsApplicationControlExecution { mode: "dormant-write-disabled"; state: "disarmed" | "armed-shadow-only" | "expired" | "closed"; lease?: { state: "armed" | "expired" | "closed"; remaining_seconds: number; authority_cached: boolean; exportable: false; } | null; orchestrator?: Record | null; publisher: { mode: "write-disabled"; denied_attempts: number; transport_calls: 0; vendor_writes_enabled: false; publisher_armed: false; automatic_retry: false; }; live_transport_installed: false; can_emit_requests: false; } export type XgridsApplicationControlPhase = | "idle" | "connecting" | "connection-ready" | "workspace-requested" | "workspace-ready" | "project-requested" | "project-ready" | "start-requested" | "initializing" | "scanning" | "stop-requested" | "stopping" | "awaiting-standby-confirmation" | "completed" | "closed" | "failed"; export interface XgridsApplicationControlSession { mode: "interactive-canonical"; state: XgridsApplicationControlPhase; control_socket_open: boolean; can_open: boolean; can_enter_workspace: boolean; can_prepare_project: boolean; can_start: boolean; can_stop: boolean; can_confirm_standby: boolean; pending_operator_action?: string | null; scripted_transitions: false; automatic_retry: false; outcome_unknown: boolean; failure?: { code?: string; reason_code?: string; message?: string; failed_phase?: string | null; dialogue_stage?: string | null; transport_state?: string | null; publish_attempts?: number | null; qos2_completions?: number | null; correlated_responses?: number | null; ignored_known_responses?: number | null; late_known_responses?: number | null; modeling_command_attempted?: boolean | null; diagnostic_snapshot_unavailable?: string[]; diagnostic_evidence_unavailable?: string[]; correlation_failure?: { phase?: string; operation_key?: string; response_topic?: string; reason_code?: string; reason?: string; } | null; compatibility_failure?: { phase?: string; operation_key?: string; response_topic?: string; reason_code?: string; expected?: Record; observed?: Record; } | null; safe_to_retry?: boolean; } | null; dialogue?: Record | null; transport?: Record | null; } export interface XgridsAcquisition { schema_version?: string; acquisition_id: string; device_id: string; device_session_id: string; compatibility_profile_id: string; control_mode: "operator-manual" | "plugin-commanded" | "observe-only"; project_name?: string | null; mount_type?: "handheld" | null; gnss_mode?: "none" | null; cleanup_pending?: boolean; requested_streams: string[]; target_host: string; duration_seconds: number | null; evidence_policy: "required" | "best-effort" | "disabled"; state: AcquisitionState; state_revision: number; created_at?: string | null; updated_at?: string | null; message_code?: string | null; operator_instructions?: string[]; result?: Record | null; } export interface XgridsOperation { schema_version?: string; operation_id: string; action: string; status: OperationStatus; accepted_at?: string | null; completed_at?: string | null; deadline_at?: string | null; device_id?: string | null; device_session_id?: string | null; idempotency_key?: string | null; stage_code?: string | null; message_code?: string | null; sequence?: number; state_revision?: number; cancellable?: boolean; cancel_requested?: boolean; result?: Record | null; error?: Record | null; evidence_refs?: string[]; } export interface XgridsK1Metrics { mqtt_to_decode_ms?: number | null; decode_ms?: number | null; publish_ms?: number | null; pipeline_ms?: number | null; end_to_end_ms?: number | null; frame_rate?: number | null; frame_rate_hz?: number | null; point_count?: number | null; dropped_preview_frames?: number | null; ai_end_to_end_ms?: number | null; ai_end_to_end_p95_ms?: number | null; ai_frame_rate_hz?: number | null; ai_dropped_frames?: number | null; ai_stale_ms?: 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; } export interface XgridsCameraPreviewDelivery { id: string; kind: "mse-fmp4-websocket"; url: string; media_type: string; } export interface XgridsCameraPreviewActivation { group_id: string; max_active: number; selected: boolean; controllable: boolean; } export interface XgridsCameraPreviewState { phase?: string | null; revision?: number | null; generation?: number | null; active_source_id?: string | null; delivery?: XgridsCameraPreviewDelivery | null; } export interface XgridsSensorCatalogStream { stream_id: string; source_id?: string | null; semantic_channel_id?: string | null; label?: string | null; sensor_kind?: string | null; modality?: string | null; availability?: string | null; endpoint_label?: string | null; activation?: XgridsCameraPreviewActivation | null; delivery?: XgridsCameraPreviewDelivery | null; decode_status?: string | null; frame_id?: string | null; coordinate_convention?: string | null; } export interface XgridsSensorCatalog { schema_version?: string | null; revision?: string | null; streams?: XgridsSensorCatalogStream[]; } export interface XgridsK1State { contract_version?: string | null; phase?: string | null; message?: string | null; devices?: BleDevice[]; selected_device_id?: string | null; k1_ip?: string | null; connection_mode?: "bridge" | "quick-connect" | "direct-connect" | null; foxglove_ws_url?: string | null; foxglove_viewer_url?: string | null; rerun_grpc_url?: string | null; viewer_settings?: ViewerSettings | null; source_mode?: SourceMode | null; metrics?: XgridsK1Metrics; compatibility?: XgridsCompatibilityState | null; modeling_control_safety?: XgridsModelingControlSafety | null; application_control_execution?: XgridsApplicationControlExecution | null; application_control_session?: XgridsApplicationControlSession | null; device_ref?: XgridsDeviceRef | null; device_session?: XgridsDeviceSession | null; acquisition?: XgridsAcquisition | null; operations?: XgridsOperation[]; last_operation?: XgridsOperation | null; sensor_catalog?: XgridsSensorCatalog | null; camera_preview?: XgridsCameraPreviewState | null; } export interface HealthResponse { ok?: boolean; status?: string; service?: string; version?: string; } export interface ScanRequest { duration_seconds?: number; } export interface CompatibilityAttestation { firmware_version: "3.0.2"; topology: "direct-lan" | "device-ap" | "controller-hotspot"; verification: "live-device-info"; } export interface ConnectRequest { device_id: string; ssid?: string; password?: string; connection_mode: "bridge" | "quick-connect" | "direct-connect"; compatibility_attestation: CompatibilityAttestation; operation_id?: string; idempotency_key?: string; } export interface PrepareAcquisitionRequest { project_name: string; mount_type: "handheld"; gnss_mode: "none"; host?: string; duration_seconds?: number; requested_streams?: RequestedStreamId[]; evidence_policy?: "required" | "best-effort" | "disabled"; compatibility_attestation: CompatibilityAttestation; operation_id?: string; idempotency_key?: string; deadline_seconds?: number; } export type RequestedStreamId = | "spatial.point-cloud.live" | "spatial.pose.live" | "device.modeling.live" | "device.status.live" | "device.heartbeat.live"; export interface StartAcquisitionRequest { acquisition_id: string; expected_state_revision?: number; operation_id?: string; idempotency_key?: string; deadline_seconds?: number; physical_acceptance?: OperatorPresenceConfirmation; } export interface StopAcquisitionRequest { acquisition_id: string; mode: "capture-only" | "graceful"; operator_confirmed?: boolean; operation_id?: string; idempotency_key?: string; deadline_seconds?: number; physical_acceptance?: OperatorPresenceConfirmation; } export interface AbortAcquisitionRequest { acquisition_id: string; operation_id?: string; idempotency_key?: string; deadline_seconds?: number; } export interface CompatibilityLiveRequest { project_name: string; host?: string; duration_seconds?: number; compatibility_attestation: CompatibilityAttestation; } export interface ReplayRequest { path: string; speed?: number; loop?: boolean; } export interface SelectCameraPreviewRequest { source_id: string; device_session_id: string; } export interface StopCameraPreviewRequest { device_session_id: string; generation: number; } export interface ShadowApplicationControlArmRequest { operator_confirmed: true; lease_seconds?: number; timezone_name: string; } export interface OperatorPresenceConfirmation { operator_present: true; owner_controlled_device: true; lixelgo_closed: true; battery_storage_confirmed: true; expected_physical_state_confirmed: true; } export interface OpenApplicationControlSessionRequest extends OperatorPresenceConfirmation { timezone_name: string; } export interface EnterApplicationWorkspaceRequest { operator_confirmed: true; } export class ApiError extends Error { readonly status: number; readonly transportUnavailable: boolean; constructor(message: string, status = 0, transportUnavailable = false) { super(message); this.name = "ApiError"; this.status = status; this.transportUnavailable = transportUnavailable; } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function unwrapState(payload: unknown): XgridsK1State { const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload; if (!isRecord(value)) { throw new ApiError("Локальный сервер вернул некорректное состояние."); } return value as XgridsK1State; } async function requestJson(path: string, init?: RequestInit): Promise { let response: Response; try { response = await fetch(path, { ...init, headers: { Accept: "application/json", ...(init?.body ? { "Content-Type": "application/json" } : {}), ...init?.headers, }, }); } catch { throw new ApiError( "Не удалось подключиться к локальному сервису устройства.", 0, true, ); } const bodyText = await response.text(); let body: unknown; if (bodyText) { try { body = JSON.parse(bodyText) as unknown; } catch { body = bodyText; } } if (!response.ok) { const detail = isRecord(body) && typeof body.detail === "string" ? body.detail : typeof body === "string" && body.trim() ? body.trim() : `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`; throw new ApiError( detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`, response.status, ); } return body; } async function postState(path: string, body?: object): Promise { const payload = await requestJson(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, }); if (payload === undefined) { return xgridsK1Api.getState(); } return unwrapState(payload); } function invokeState(actionId: string, input: object = {}): Promise { return postState( `/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/actions/${encodeURIComponent(actionId)}`, { input }, ); } export const xgridsK1Api = { async getHealth(): Promise { const payload = await requestJson("/api/health"); if (!isRecord(payload)) { throw new ApiError("Локальный сервер вернул некорректный ответ проверки."); } return payload as HealthResponse; }, async getState(): Promise { return invokeState(xgridsK1Actions.stateRead); }, scanBle(body: ScanRequest = {}): Promise { return invokeState(xgridsK1Actions.discoveryScan, body); }, connect(body: ConnectRequest): Promise { return invokeState(xgridsK1Actions.networkProvision, body); }, prepareAcquisition(body: PrepareAcquisitionRequest): Promise { return invokeState(xgridsK1Actions.acquisitionPrepare, body); }, startAcquisition(body: StartAcquisitionRequest): Promise { return invokeState(xgridsK1Actions.acquisitionStart, body); }, stopAcquisition(body: StopAcquisitionRequest): Promise { return invokeState(xgridsK1Actions.acquisitionStop, body); }, abortAcquisition(body: AbortAcquisitionRequest): Promise { return invokeState(xgridsK1Actions.acquisitionAbort, body); }, startReplay(body: ReplayRequest): Promise { return invokeState(xgridsK1Actions.streamStartReplay, body); }, startLiveCompatibility(body: CompatibilityLiveRequest): Promise { return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body); }, stopSessionCompatibility(): Promise { return invokeState(xgridsK1Actions.compatibilityStreamStop); }, selectCameraPreview(body: SelectCameraPreviewRequest): Promise { return invokeState(xgridsK1Actions.cameraPreviewSelect, body); }, stopCameraPreview(body: StopCameraPreviewRequest): Promise { return invokeState(xgridsK1Actions.cameraPreviewStop, body); }, updateViewerSettings(body: ViewerSettings): Promise { return invokeState(xgridsK1Actions.viewerSettingsUpdate, body); }, getShadowApplicationControlState(): Promise { return invokeState(xgridsK1Actions.applicationControlShadowState); }, armShadowApplicationControl( body: ShadowApplicationControlArmRequest, ): Promise { return invokeState(xgridsK1Actions.applicationControlShadowArm, body); }, disarmShadowApplicationControl(): Promise { return invokeState(xgridsK1Actions.applicationControlShadowDisarm); }, openApplicationControlSession( body: OpenApplicationControlSessionRequest, ): Promise { return invokeState(xgridsK1Actions.applicationControlSessionOpen, body); }, enterApplicationWorkspace( body: EnterApplicationWorkspaceRequest, ): Promise { return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body); }, closeApplicationControlSession(): Promise { return invokeState(xgridsK1Actions.applicationControlSessionClose); }, }; export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; function eventSocketUrl(): string { const url = new URL( `/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/events`, window.location.href, ); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } export function openEventSocket( onState: (state: XgridsK1State) => void, onStatus: (status: EventSocketStatus) => void, ): () => void { onStatus("connecting"); const socket = new WebSocket(eventSocketUrl()); socket.addEventListener("open", () => onStatus("open")); socket.addEventListener("message", (event) => { try { const payload = JSON.parse(String(event.data)) as unknown; onState(unwrapState(payload)); } catch { // The REST poll remains authoritative if an unrelated event is received. } }); socket.addEventListener("error", () => onStatus("error")); socket.addEventListener("close", () => onStatus("closed")); return () => socket.close(1000, "Пункт управления закрыт"); }