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 interface K1Metrics { 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 ConsoleState { 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; source_mode?: SourceMode | null; metrics?: K1Metrics; } export interface HealthResponse { ok?: boolean; status?: string; service?: string; version?: string; } export interface ScanRequest { duration_seconds?: number; } export interface ConnectRequest { device_id: string; ssid: string; password: string; } export interface LiveRequest { host?: string; duration_seconds?: number; } export interface ReplayRequest { path: string; speed?: number; loop?: boolean; } 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } function unwrapState(payload: unknown): ConsoleState { const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload; if (!isRecord(value)) { throw new ApiError("Локальный сервер вернул некорректное состояние."); } return value as ConsoleState; } 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("Не удалось подключиться к локальному API K1."); } 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 K1 завершился ошибкой HTTP ${response.status}.`; throw new ApiError( detail || `Запрос к API K1 завершился ошибкой 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 api.getState(); } return unwrapState(payload); } export const api = { async getHealth(): Promise { const payload = await requestJson("/api/health"); if (!isRecord(payload)) { throw new ApiError("Локальный сервер вернул некорректный ответ проверки."); } return payload as HealthResponse; }, async getState(): Promise { return unwrapState(await requestJson("/api/state")); }, scanBle(body: ScanRequest = {}): Promise { return postState("/api/ble/scan", body); }, connect(body: ConnectRequest): Promise { return postState("/api/connect", body); }, startLive(body: LiveRequest = {}): Promise { return postState("/api/session/live", body); }, startReplay(body: ReplayRequest): Promise { return postState("/api/session/replay", body); }, stopSession(): Promise { return postState("/api/session/stop"); }, }; export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; function eventSocketUrl(): string { const url = new URL("/api/events", window.location.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } export function openEventSocket( onState: (state: ConsoleState) => 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, "Консоль K1 закрыта"); }