208 lines
5.3 KiB
TypeScript
208 lines
5.3 KiB
TypeScript
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<string, unknown> {
|
||
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<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("Не удалось подключиться к локальному 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<ConsoleState> {
|
||
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<HealthResponse> {
|
||
const payload = await requestJson("/api/health");
|
||
if (!isRecord(payload)) {
|
||
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
|
||
}
|
||
return payload as HealthResponse;
|
||
},
|
||
|
||
async getState(): Promise<ConsoleState> {
|
||
return unwrapState(await requestJson("/api/state"));
|
||
},
|
||
|
||
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
|
||
return postState("/api/ble/scan", body);
|
||
},
|
||
|
||
connect(body: ConnectRequest): Promise<ConsoleState> {
|
||
return postState("/api/connect", body);
|
||
},
|
||
|
||
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
|
||
return postState("/api/session/live", body);
|
||
},
|
||
|
||
startReplay(body: ReplayRequest): Promise<ConsoleState> {
|
||
return postState("/api/session/replay", body);
|
||
},
|
||
|
||
stopSession(): Promise<ConsoleState> {
|
||
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 закрыта");
|
||
}
|