148 lines
4.1 KiB
TypeScript
148 lines
4.1 KiB
TypeScript
/**
|
||
* Connection + sample store. Owns the WebSocket, an optional REST history
|
||
* prefetch, auto-reconnect with backoff, and the growing sample array.
|
||
* Mirrors the pattern of leaflet_svelte's telemetryStore.svelte.ts.
|
||
*/
|
||
import { parsePacket, historyUrlFromWs, type RawPacket, type Sample } from './telemetry';
|
||
|
||
export type ConnStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
||
|
||
const LS_KEY = 'tdash.wsUrl';
|
||
const RECONNECT_MAX_S = 30;
|
||
|
||
class TelemetryStore {
|
||
wsUrl = $state(
|
||
localStorage.getItem(LS_KEY) ??
|
||
(import.meta.env.VITE_DEFAULT_WS_URL as string | undefined) ??
|
||
'ws://localhost:8000/api/ws/satellite/<uuid>/telemetry/',
|
||
);
|
||
status = $state<ConnStatus>('idle');
|
||
error = $state<string | null>(null);
|
||
samples = $state<Sample[]>([]);
|
||
packetsReceived = $state(0);
|
||
/** Ticks once a second — drives the "age of last packet" display. */
|
||
nowS = $state(Math.floor(Date.now() / 1000));
|
||
|
||
#ws: WebSocket | null = null;
|
||
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||
#backoffS = 1;
|
||
#wantConnected = false;
|
||
|
||
constructor() {
|
||
setInterval(() => (this.nowS = Math.floor(Date.now() / 1000)), 1000);
|
||
}
|
||
|
||
get latest(): Sample | null {
|
||
return this.samples[this.samples.length - 1] ?? null;
|
||
}
|
||
|
||
/** Seconds since the last packet arrived (wall clock), or null before data. */
|
||
get lastPacketAge(): number | null {
|
||
const last = this.latest;
|
||
if (!last) return null;
|
||
return Math.max(0, this.nowS - (this.#lastRxS ?? last.t));
|
||
}
|
||
|
||
#lastRxS: number | null = null;
|
||
|
||
async connect(): Promise<void> {
|
||
const url = this.wsUrl.trim();
|
||
if (!/^wss?:\/\//.test(url)) {
|
||
this.status = 'error';
|
||
this.error = 'Адрес должен начинаться с ws:// или wss://';
|
||
return;
|
||
}
|
||
localStorage.setItem(LS_KEY, url);
|
||
this.disconnect();
|
||
this.#wantConnected = true;
|
||
this.samples = [];
|
||
this.packetsReceived = 0;
|
||
this.error = null;
|
||
this.status = 'connecting';
|
||
|
||
// History prefetch — best-effort, only for the known stratoflights URL shape.
|
||
const histUrl = historyUrlFromWs(url);
|
||
if (histUrl) {
|
||
try {
|
||
const res = await fetch(histUrl);
|
||
if (res.ok) {
|
||
const body = (await res.json()) as RawPacket[] | { results: RawPacket[] };
|
||
const list = Array.isArray(body) ? body : (body.results ?? []);
|
||
// API returns newest-first; reverse to chronological order.
|
||
this.samples = [...list]
|
||
.reverse()
|
||
.map(parsePacket)
|
||
.filter((s): s is Sample => s !== null);
|
||
}
|
||
} catch {
|
||
// non-fatal: live stream still works without history
|
||
}
|
||
}
|
||
if (!this.#wantConnected) return; // disconnected while fetching history
|
||
this.#open(url);
|
||
}
|
||
|
||
#open(url: string): void {
|
||
const ws = new WebSocket(url);
|
||
this.#ws = ws;
|
||
|
||
ws.onopen = () => {
|
||
this.status = 'connected';
|
||
this.error = null;
|
||
this.#backoffS = 1;
|
||
};
|
||
|
||
ws.onmessage = ({ data }) => {
|
||
try {
|
||
const raw = JSON.parse(data) as { error?: unknown } & RawPacket;
|
||
if (raw.error) {
|
||
this.error = String(raw.error);
|
||
return;
|
||
}
|
||
const sample = parsePacket(raw);
|
||
if (sample) {
|
||
// ponytail: uPlot needs strictly ascending x — drop out-of-order packets
|
||
const last = this.latest;
|
||
if (!last || sample.t > last.t) {
|
||
this.samples.push(sample);
|
||
}
|
||
this.packetsReceived++;
|
||
this.#lastRxS = Math.floor(Date.now() / 1000);
|
||
}
|
||
} catch {
|
||
// ignore malformed frames
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
this.#ws = null;
|
||
if (!this.#wantConnected) {
|
||
this.status = 'idle';
|
||
return;
|
||
}
|
||
// Unwanted close (server restart, network drop) — auto-reconnect.
|
||
this.status = 'reconnecting';
|
||
this.#reconnectTimer = setTimeout(() => {
|
||
if (this.#wantConnected) this.#open(url);
|
||
}, this.#backoffS * 1000);
|
||
this.#backoffS = Math.min(this.#backoffS * 2, RECONNECT_MAX_S);
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
if (this.status === 'connecting') {
|
||
this.error = 'Не удалось подключиться';
|
||
}
|
||
};
|
||
}
|
||
|
||
disconnect(): void {
|
||
this.#wantConnected = false;
|
||
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
|
||
this.#reconnectTimer = null;
|
||
this.#ws?.close();
|
||
this.#ws = null;
|
||
this.status = 'idle';
|
||
}
|
||
}
|
||
|
||
export const store = new TelemetryStore();
|