NODEDC_MISSION_CORE/plugins/xgrids-k1/frontend/src/api.ts

533 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
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<string, unknown> | 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 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;
cleanup_pending?: boolean;
requested_streams: string[];
target_host: string;
duration_seconds: number;
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<string, unknown> | 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<string, unknown> | null;
error?: Record<string, unknown> | 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;
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;
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;
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";
operator_confirmed: true;
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
}
export interface PrepareAcquisitionRequest {
project_name: string;
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;
}
export interface StopAcquisitionRequest {
acquisition_id: string;
mode: "capture-only" | "graceful";
operator_confirmed?: boolean;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
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 class ApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<unknown> {
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("Не удалось подключиться к локальному сервису устройства.");
}
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<XgridsK1State> {
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<XgridsK1State> {
return postState(
`/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/actions/${encodeURIComponent(actionId)}`,
{ input },
);
}
export const xgridsK1Api = {
async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health");
if (!isRecord(payload)) {
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
}
return payload as HealthResponse;
},
async getState(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.stateRead);
},
scanBle(body: ScanRequest = {}): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.discoveryScan, body);
},
connect(body: ConnectRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.networkProvision, body);
},
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
startAcquisition(body: StartAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStart, body);
},
stopAcquisition(body: StopAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStop, body);
},
abortAcquisition(body: AbortAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionAbort, body);
},
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
},
stopSessionCompatibility(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
selectCameraPreview(body: SelectCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewSelect, body);
},
stopCameraPreview(body: StopCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewStop, body);
},
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
},
getShadowApplicationControlState(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowState);
},
armShadowApplicationControl(
body: ShadowApplicationControlArmRequest,
): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowArm, body);
},
disarmShadowApplicationControl(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.applicationControlShadowDisarm);
},
};
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, "Пункт управления закрыт");
}