feat(plugins): isolate device integrations
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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 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";
|
||||
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;
|
||||
[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;
|
||||
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 {
|
||||
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.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 {
|
||||
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 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);
|
||||
},
|
||||
};
|
||||
|
||||
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, "Пункт управления закрыт");
|
||||
}
|
||||
Reference in New Issue
Block a user