533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
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, "Пункт управления закрыт");
|
||
}
|